diff --git a/docs/reviews/messaging/README.md b/docs/reviews/messaging/README.md index 7f13b04..b9a9ebb 100644 --- a/docs/reviews/messaging/README.md +++ b/docs/reviews/messaging/README.md @@ -3,8 +3,8 @@ 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. +complete Signal feature parity. The subsequent quoted-reply and editing drafts +extend this stack; attachments remain outside this work. The implementation extends Fireplace's Flutter widgets and local store. Signal's [iOS](https://github.com/signalapp/Signal-iOS) and diff --git a/docs/reviews/replies-and-edits/README.md b/docs/reviews/replies-and-edits/README.md new file mode 100644 index 0000000..2e967f4 --- /dev/null +++ b/docs/reviews/replies-and-edits/README.md @@ -0,0 +1,70 @@ +# Quoted replies and sent-message editing + +These two drafts extend the messaging stack from PRs 45–48. The reply draft adds +encrypted quote snapshots, a cancellable composer preview, Reply menu and +accessibility actions, swipe-right composition, and sent quotes. The editing +draft adds double-tap/menu editing, an independent edit draft, checkmark save, +cancel restoring the ordinary draft, an Edited label and version history. It +also makes quote taps scroll within the full timeline, including lazy rows. + +The interaction reference is Signal iOS. Fireplace keeps its colors, protocol, +privacy rules and attachment-free scope. This report does not claim exhaustive +pixel or feature parity. Physical iOS/Android gesture and native-font acceptance +remains a human review step. + +Read [decision 0022](../../decisions/0022-quoted-replies-and-message-edits.md) +for v3 content payloads, bounded snapshots/history, author validation, concurrent +linked-device ordering, deletion dominance, compatibility and failure recovery. +Ordinary text remains v1. No new Firestore fields, rules, keys or packages. + +## Renders + +Actual Flutter screenshots, fictional fixtures, UTC and the installed Windows +emoji font as a test-only fallback. No font is bundled. + +| State | Screenshot | +| --- | --- | +| Reply composer | [Dark](reply-compose-dark.png) | +| Sent quote | [Light](reply-sent-light.png) | +| Tap to original | [Dark](reply-original-dark.png) | +| Enlarged reply composer | [Light](reply-large-light.png) | +| Editing | [Dark](edit-compose-dark.png) | +| Edited bubble | [Light](edit-saved-light.png) | +| Edit history | [Light](edit-history-light.png) | +| Enlarged edit composer | [Dark](edit-large-dark.png) | + +## Validation + +Pinned Flutter 3.47.6. Each draft was checked in a fresh local clone. +The reply draft passed format/analyze, 58 targeted tests, nine menu/accessibility +edge tests and eight reply renders. The additional accessibility inventory now +includes Reply. + +| Check | Result | +| --- | --- | +| Final format and `flutter analyze` | Clean | +| Full non-timing suite, including chat simulations | 834 passed, 1 existing skip | +| Timing suite, run alone with concurrency 1 | 16 passed | +| Render catalog | 168 passed; every PNG hash matched the fresh checkout | +| Layer check and checker tests | Passed; 10 checker tests | +| Android build refusal checks | All 8 passed; no signing or app build | +| Separate Firestore emulator rules suite | 74 passed, unchanged rules | + +The rules run used a demo project and unused local emulator ports in the separate +backend checkout. No production endpoint or deployment was used. Tests use +fictional data. Existing long-file warnings concern the original crypto source +and the chat screen at approximately 500 lines. + +Reproduce the catalog with `TZ=UTC`, +`RENDER_EMOJI_FONT=C:/Windows/Fonts/seguiemj.ttf`, and +`flutter test scripts/render/render_screens_test.dart`. +The final catalog hashes are in [render-sha256.csv](render-sha256.csv). + +The earlier messaging PRs were merged during validation. The new branches were +rebased onto that main revision; Git tree hashes confirmed no source changes. + +## AI assistance + +OpenAI Codex wrote the implementation, design note, tests and review material. +Human review, physical-device acceptance and CLA confirmation remain pending. +Nothing was merged or deployed. diff --git a/docs/reviews/replies-and-edits/edit-compose-dark.png b/docs/reviews/replies-and-edits/edit-compose-dark.png new file mode 100644 index 0000000..e7f4d07 Binary files /dev/null and b/docs/reviews/replies-and-edits/edit-compose-dark.png differ diff --git a/docs/reviews/replies-and-edits/edit-history-light.png b/docs/reviews/replies-and-edits/edit-history-light.png new file mode 100644 index 0000000..2245194 Binary files /dev/null and b/docs/reviews/replies-and-edits/edit-history-light.png differ diff --git a/docs/reviews/replies-and-edits/edit-large-dark.png b/docs/reviews/replies-and-edits/edit-large-dark.png new file mode 100644 index 0000000..a8f1073 Binary files /dev/null and b/docs/reviews/replies-and-edits/edit-large-dark.png differ diff --git a/docs/reviews/replies-and-edits/edit-saved-light.png b/docs/reviews/replies-and-edits/edit-saved-light.png new file mode 100644 index 0000000..b2ba71c Binary files /dev/null and b/docs/reviews/replies-and-edits/edit-saved-light.png differ diff --git a/docs/reviews/replies-and-edits/render-sha256.csv b/docs/reviews/replies-and-edits/render-sha256.csv new file mode 100644 index 0000000..dcb16bb --- /dev/null +++ b/docs/reviews/replies-and-edits/render-sha256.csv @@ -0,0 +1,169 @@ +"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","D7D332AE586BBAC39517063684108420155BD22B213A3D134EA1760A6BC2902D" +"dark-message-actions-narrow-large.png","8FBB8B7BAC220A2CA8C771A88F613A5371AE7D2265C88654FE5A3BA5AA25C2A3" +"dark-message-actions-outgoing.png","13B2876CC4036E0CFD190A93BC075922A7259E4C921951A3AEFF11C98CBF51CF" +"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-edit-composing.png","26C9FE96B5C97790A3CBFA5FF685747AF7CB6615E600B099B41175A543B7DA26" +"dark-messages-edit-history.png","112E69A68CC6CC5E75E6F66EE98C69E2941E8DD91769DB6B72D09BEA54A7C68A" +"dark-messages-edit-large.png","0C8087F2B3D50DCE8B785EC9BB5DABB7E301F6119AF9B8F58827A5A4D8CF27D0" +"dark-messages-edit-saved.png","A958613DAD36D9752F0508A279CCE0398081DEDEC64DF6D0D05ECDE6DCBE1018" +"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-reply-composing.png","EF610CFB3E9A35C9D9DF0B6CF74AE977097694A36019E84CAF217C9F992D10AD" +"dark-messages-reply-large.png","23C3DB5DAAD4296E6FA6E85DCFC0136B556EF5BFFEB551874711E4D285FBFC6B" +"dark-messages-reply-original.png","D3476A8E95E1731CF67EAC04EEAC2ABF0128D10EBFDEEBD96D56A7858839EEBC" +"dark-messages-reply-sent.png","D3476A8E95E1731CF67EAC04EEAC2ABF0128D10EBFDEEBD96D56A7858839EEBC" +"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","044E5E1F898E51A450437EA5F719536A10BDF86CDBE5E181C9D91C5DE80D8276" +"light-message-actions-narrow-large.png","A4EE2040F6B7154F01B08BC19B6B75C17D56BC9389EFC61AD72C189115D35C1A" +"light-message-actions-outgoing.png","B98A0165876DCB7D95965B51C3BBECBFF24FC8F165DD15353B45CD2940D58444" +"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-edit-composing.png","C3E2F29B91BFF870DA9199F8899B1FC23351B2D07D116729B903AB1E815EAD1F" +"light-messages-edit-history.png","29CB71EC70FCCA4F6DD4E679369E18A31C203CCAD9AC10179D9EED11A9EE54DC" +"light-messages-edit-large.png","5D61B93D7D01AA343E11D9E3454F5283BDB761E3D7187BEF74B2460A26E82B71" +"light-messages-edit-saved.png","9C34760E115F3BE7EB978B6E062264A1EFAD46846B76D10FE521F11DDF94BACE" +"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-reply-composing.png","8EB22F9924D52B4E1C465366E71A223F39475B03A0EE85AC38AC4400E7CB50A4" +"light-messages-reply-large.png","FCF02D7E4884D2C8047252EE0AB0403417CE1424D5D7035D774D43F43EC92B6B" +"light-messages-reply-original.png","F9D9E55FDE99E47C3B28BEDC0793101FF4C72C16BA9339637B7E24AE332D4CE9" +"light-messages-reply-sent.png","F9D9E55FDE99E47C3B28BEDC0793101FF4C72C16BA9339637B7E24AE332D4CE9" +"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/docs/reviews/replies-and-edits/reply-compose-dark.png b/docs/reviews/replies-and-edits/reply-compose-dark.png new file mode 100644 index 0000000..1aa0642 Binary files /dev/null and b/docs/reviews/replies-and-edits/reply-compose-dark.png differ diff --git a/docs/reviews/replies-and-edits/reply-large-light.png b/docs/reviews/replies-and-edits/reply-large-light.png new file mode 100644 index 0000000..7c3ebf2 Binary files /dev/null and b/docs/reviews/replies-and-edits/reply-large-light.png differ diff --git a/docs/reviews/replies-and-edits/reply-original-dark.png b/docs/reviews/replies-and-edits/reply-original-dark.png new file mode 100644 index 0000000..27f377f Binary files /dev/null and b/docs/reviews/replies-and-edits/reply-original-dark.png differ diff --git a/docs/reviews/replies-and-edits/reply-sent-light.png b/docs/reviews/replies-and-edits/reply-sent-light.png new file mode 100644 index 0000000..d59cb0a Binary files /dev/null and b/docs/reviews/replies-and-edits/reply-sent-light.png differ diff --git a/lib/src/db/local_messages.dart b/lib/src/db/local_messages.dart index b79ebc4..02b526c 100644 --- a/lib/src/db/local_messages.dart +++ b/lib/src/db/local_messages.dart @@ -1,3 +1,4 @@ +import 'package:fireplace/src/db/message_edit.dart'; import 'package:fireplace/src/db/message_quote.dart'; import 'dart:async'; @@ -29,9 +30,13 @@ class LocalMessage { this.remotelyDeleted = false, this.mutation, this.quote, + this.originalBody, + this.editClock = 0, + Map edits = const {}, Map reactions = const {}, Map reactionClocks = const {}, - }) : reactions = Map.unmodifiable(reactions), + }) : edits = Map.unmodifiable(edits), + reactions = Map.unmodifiable(reactions), reactionClocks = Map.unmodifiable(reactionClocks); final String id; @@ -46,6 +51,9 @@ class LocalMessage { final bool remotelyDeleted; final MessageMutation? mutation; final MessageQuote? quote; + final String? originalBody; + final int editClock; + final Map edits; final Map reactions; final Map reactionClocks; bool get visible => !locallyDeleted && mutation == null; @@ -53,6 +61,9 @@ class LocalMessage { LocalMessage copyWith({ String? body, MessageQuote? quote, + String? originalBody, + int? editClock, + Map? edits, bool? outgoing, MessageStatus? status, bool? remotelyDeleted, @@ -71,6 +82,9 @@ class LocalMessage { remotelyDeleted: remotelyDeleted ?? this.remotelyDeleted, mutation: mutation, quote: quote ?? this.quote, + originalBody: originalBody ?? this.originalBody, + editClock: editClock ?? this.editClock, + edits: edits ?? this.edits, reactions: reactions ?? this.reactions, reactionClocks: reactionClocks ?? this.reactionClocks, ); @@ -107,6 +121,12 @@ class LocalMessage { if (remotelyDeleted) 'remotelyDeleted': true, if (mutation != null) 'mutation': mutation!.toJson(), if (quote != null) 'quote': quote!.toJson(), + if (originalBody != null) 'originalBody': originalBody, + if (editClock != 0) 'editClock': editClock, + if (edits.isNotEmpty) + 'edits': { + for (final e in edits.entries) e.key.toString(): e.value.toJson(), + }, if (reactions.isNotEmpty) 'reactions': {for (final e in reactions.entries) e.key: e.value.toJson()}, if (reactionClocks.isNotEmpty) 'reactionClocks': reactionClocks, @@ -120,6 +140,14 @@ class LocalMessage { outgoing: j['outgoing'], sentAt: DateTime.fromMillisecondsSinceEpoch(j['sentAt']), body: j['body'], + originalBody: j['originalBody'], + editClock: j['editClock'] ?? 0, + edits: { + for (final e in (j['edits'] as Map? ?? {}).entries) + int.parse(e.key): MessageEdit.fromJson( + Map.from(e.value), + ), + }, quote: j['quote'] == null ? null : MessageQuote.fromJson(Map.from(j['quote'])), diff --git a/lib/src/db/message_edit.dart b/lib/src/db/message_edit.dart new file mode 100644 index 0000000..9dbfdf2 --- /dev/null +++ b/lib/src/db/message_edit.dart @@ -0,0 +1,32 @@ +/// One winning revision slot. Ties from linked devices have a stable ordering. +class MessageEdit { + const MessageEdit({ + required this.revision, + required this.body, + required this.deviceId, + required this.eventId, + required this.sentAt, + }); + final int revision; + final String body, deviceId, eventId; + final DateTime sentAt; + bool newerThan(MessageEdit other) { + final device = deviceId.compareTo(other.deviceId); + return device != 0 ? device > 0 : eventId.compareTo(other.eventId) > 0; + } + + Map toJson() => { + 'revision': revision, + 'body': body, + 'deviceId': deviceId, + 'eventId': eventId, + 'sentAt': sentAt.millisecondsSinceEpoch, + }; + factory MessageEdit.fromJson(Map j) => MessageEdit( + revision: j['revision'], + body: j['body'], + deviceId: j['deviceId'], + eventId: j['eventId'], + sentAt: DateTime.fromMillisecondsSinceEpoch(j['sentAt']), + ); +} diff --git a/lib/src/db/message_mutation.dart b/lib/src/db/message_mutation.dart index 4f8c9e1..995bc7c 100644 --- a/lib/src/db/message_mutation.dart +++ b/lib/src/db/message_mutation.dart @@ -1,4 +1,4 @@ -enum MessageMutationKind { reaction, delete } +enum MessageMutationKind { reaction, delete, edit } enum ReactionOperation { set, clear } @@ -7,17 +7,22 @@ class MessageMutation { required this.kind, required this.targetId, this.emoji = '', + this.body = '', this.operation = ReactionOperation.set, this.revision = 0, }); final MessageMutationKind kind; - final String targetId, emoji; + final String targetId, emoji, body; final ReactionOperation operation; final int revision; Map toJson() => { 'type': kind.name, 'targetId': targetId, + if (kind == MessageMutationKind.edit) ...{ + 'body': body, + 'revision': revision, + }, if (kind == MessageMutationKind.reaction) ...{ 'emoji': emoji, 'operation': operation.name, @@ -35,6 +40,24 @@ class MessageMutation { if (kind == MessageMutationKind.delete) { return MessageMutation(kind: kind, targetId: target); } + if (kind == MessageMutationKind.edit) { + final body = j['body'], revision = j['revision']; + if (body is! String || + body.length > 32768 || + body.runes.length > 16384 || + body.trim().isEmpty || + revision is! int || + revision < 1 || + revision > 10) { + throw const FormatException('Invalid edit'); + } + return MessageMutation( + kind: kind, + targetId: target, + body: body, + revision: revision, + ); + } final emoji = j['emoji'], revision = j['revision']; if (emoji is! String || !validReactionEmoji(emoji) || diff --git a/lib/src/db/message_projection.dart b/lib/src/db/message_projection.dart index 7a2409d..03602f6 100644 --- a/lib/src/db/message_projection.dart +++ b/lib/src/db/message_projection.dart @@ -1,3 +1,4 @@ +import 'package:fireplace/src/db/message_edit.dart'; import 'package:fireplace/src/db/local_messages.dart'; import 'package:fireplace/src/db/message_mutation.dart'; @@ -8,6 +9,8 @@ Map projectMessageRecords( ) { final next = {...records}; final deleted = {}; + final edits = >{}; + final editClocks = {}; final reactions = >{}; final clocks = >{}; for (final event in records.values) { @@ -23,6 +26,31 @@ Map projectMessageRecords( next[event.id] = LocalMessage.tombstone(event.chatId, event.id); continue; } + if (change.kind == MessageMutationKind.edit) { + if (target.senderUid != event.senderUid || + target.status != MessageStatus.ok || + target.remotelyDeleted || + change.revision < 1 || + change.revision > 10) { + continue; + } + final clock = editClocks[target.id] ?? target.editClock; + if (change.revision > clock) editClocks[target.id] = change.revision; + if (event.status != MessageStatus.ok) continue; + final versions = edits.putIfAbsent(target.id, () => {...target.edits}); + final revision = MessageEdit( + revision: change.revision, + body: change.body, + deviceId: event.senderDevice, + eventId: event.id, + sentAt: event.sentAt, + ); + final old = versions[change.revision]; + if (old == null || revision.newerThan(old)) { + versions[change.revision] = revision; + } + continue; + } if (change.kind == MessageMutationKind.reaction) { final byActor = clocks.putIfAbsent( target.id, @@ -58,6 +86,9 @@ Map projectMessageRecords( if (target.remotelyDeleted || deleted.contains(target.id)) { next[target.id] = target.copyWith( body: '', + originalBody: '', + edits: const {}, + editClock: 0, remotelyDeleted: true, reactions: const {}, reactionClocks: const {}, @@ -70,10 +101,27 @@ Map projectMessageRecords( ); } } + for (final entry in editClocks.entries) { + final target = next[entry.key]!; + if (!target.remotelyDeleted) { + next[entry.key] = target.copyWith(editClock: entry.value); + } + } + for (final entry in edits.entries) { + final target = next[entry.key]!; + if (target.remotelyDeleted) continue; + final last = entry.value.keys.reduce((a, b) => a > b ? a : b); + next[entry.key] = target.copyWith( + originalBody: target.originalBody ?? target.body, + edits: entry.value, + body: entry.value[last]!.body, + ); + } // A deleted target must not leave reaction content in hidden control records. for (final event in records.values) { final change = event.mutation; - if (change?.kind == MessageMutationKind.reaction && + if ((change?.kind == MessageMutationKind.reaction || + change?.kind == MessageMutationKind.edit) && next[change!.targetId]?.remotelyDeleted == true) { next[event.id] = LocalMessage.tombstone(event.chatId, event.id); } diff --git a/lib/src/model/chat/chat_service.dart b/lib/src/model/chat/chat_service.dart index 7f2e766..7437e1c 100644 --- a/lib/src/model/chat/chat_service.dart +++ b/lib/src/model/chat/chat_service.dart @@ -208,6 +208,16 @@ class ChatService { ), ); + Future editMessage(String chatId, String targetId, String body) => + _sender.sendControl( + chatId, + MessageMutation( + kind: MessageMutationKind.edit, + targetId: targetId, + body: body, + ), + ); + Future react(String chatId, String targetId, String emoji) => _sender.sendControl( chatId, diff --git a/lib/src/model/chat/message_compose_target.dart b/lib/src/model/chat/message_compose_target.dart index 9e47bb6..2bf4aae 100644 --- a/lib/src/model/chat/message_compose_target.dart +++ b/lib/src/model/chat/message_compose_target.dart @@ -2,12 +2,14 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fireplace/src/app/providers.dart'; import 'package:fireplace/src/db/local_messages.dart'; -enum MessageComposeAction { reply } +enum MessageComposeAction { reply, edit } class MessageComposeTarget { const MessageComposeTarget(this.action, this.message); final MessageComposeAction action; final LocalMessage message; + String? get replyId => + action == MessageComposeAction.reply ? message.id : null; } class MessageCompose extends Notifier { @@ -24,7 +26,12 @@ class MessageCompose extends Notifier { void reply(LocalMessage message) => state = MessageComposeTarget(MessageComposeAction.reply, message); + void edit(LocalMessage message) => + state = MessageComposeTarget(MessageComposeAction.edit, message); void clear() => state = null; + void clearReply() { + if (state?.action == MessageComposeAction.reply) state = null; + } } final messageComposeProvider = NotifierProvider.autoDispose diff --git a/lib/src/model/chat/message_content.dart b/lib/src/model/chat/message_content.dart index 5683ea4..6e6fd67 100644 --- a/lib/src/model/chat/message_content.dart +++ b/lib/src/model/chat/message_content.dart @@ -54,6 +54,26 @@ prepareMessageContent({ 'Only your messages from the last 24 hours can be deleted for everyone.', ); } + } else if (mutation.kind == MessageMutationKind.edit) { + final age = DateTime.now().difference(target.sentAt); + if (target.senderUid != uid || + age.isNegative || + age > const Duration(hours: 24) || + target.editClock >= 10) { + throw ChatException( + 'You can edit your messages up to 10 times within 24 hours.', + ); + } + if (mutation.body.trim() == target.body) { + throw ChatException('Make a change before saving.'); + } + if (messageTooLong(mutation.body)) throw ChatException(messageLimitError); + mutation = MessageMutation( + kind: MessageMutationKind.edit, + targetId: target.id, + body: mutation.body.trim(), + revision: target.editClock + 1, + ); } else { final previous = target.reactions[uid]; mutation = MessageMutation( diff --git a/lib/src/model/chat/message_controls.dart b/lib/src/model/chat/message_controls.dart index 835c702..69b4bb6 100644 --- a/lib/src/model/chat/message_controls.dart +++ b/lib/src/model/chat/message_controls.dart @@ -23,32 +23,36 @@ class MessageControls extends Notifier { return const MessageControlState(); } + Future edit(String id, String body) => + _run((chat) => chat.editMessage(chatId, id, body)); + Future react(String id, String emoji) => _run((chat) => chat.react(chatId, id, emoji)); Future delete(String id) => _run((chat) => chat.deleteForEveryone(chatId, id)); - Future _run(Future Function(ChatService) action) async { + Future _run(Future Function(ChatService) action) async { final owner = ref.read(appSessionProvider).value; final summary = ref.read(chatSummaryProvider(chatId)); if (state.busy || owner == null || summary == null || !ref.read(chatActivityProvider.notifier).eligible(summary)) { - return; + return false; } state = const MessageControlState(busy: true); try { await action(owner.chat); if (!ref.mounted || !identical(ref.read(appSessionProvider).value, owner)) { - return; + return false; } state = const MessageControlState(); + return true; } on SendNotConfirmedException catch (e) { if (!ref.mounted || !identical(ref.read(appSessionProvider).value, owner)) { - return; + return false; } state = MessageControlState( pending: e, @@ -56,21 +60,23 @@ class MessageControls extends Notifier { ? 'Sent, but could not save the change on this device.' : 'Could not confirm the change. It may have reached them. Nothing was sent again.', ); + return true; // The attempt moved to explicit recovery; never leave a resend-ready edit. } on ChatException catch (e) { if (!ref.mounted || !identical(ref.read(appSessionProvider).value, owner)) { - return; + return false; } state = MessageControlState(notice: e.message); } catch (_) { if (!ref.mounted || !identical(ref.read(appSessionProvider).value, owner)) { - return; + return false; } state = const MessageControlState( notice: 'Could not make this change. Try again.', ); } + return false; } Future resolvePending() async { diff --git a/lib/src/model/chat/message_receiver.dart b/lib/src/model/chat/message_receiver.dart index c4d61ab..62e74c6 100644 --- a/lib/src/model/chat/message_receiver.dart +++ b/lib/src/model/chat/message_receiver.dart @@ -442,8 +442,10 @@ class MessageReceiver { body = payload['body'] as String; status = MessageStatus.ok; } else if (payload is Map && - payload['v'] == 2 && - (payload['type'] == 'reaction' || payload['type'] == 'delete')) { + ((payload['v'] == 2 && + (payload['type'] == 'reaction' || + payload['type'] == 'delete')) || + (payload['v'] == 3 && payload['type'] == 'edit'))) { mutation = MessageMutation.fromJson(Map.from(payload)); body = ''; status = MessageStatus.ok; diff --git a/lib/src/model/chat/message_sender.dart b/lib/src/model/chat/message_sender.dart index 94a4e82..add9319 100644 --- a/lib/src/model/chat/message_sender.dart +++ b/lib/src/model/chat/message_sender.dart @@ -173,7 +173,7 @@ class MessageSender { .where((d) => d.deviceId != device.keys.deviceId); final plain = utf8.encode( jsonEncode({ - 'v': quote != null + 'v': quote != null || mutation?.kind == MessageMutationKind.edit ? 3 : mutation == null ? 1 diff --git a/lib/src/model/chat/send_controller.dart b/lib/src/model/chat/send_controller.dart index 9da21db..ddef781 100644 --- a/lib/src/model/chat/send_controller.dart +++ b/lib/src/model/chat/send_controller.dart @@ -18,6 +18,7 @@ class SendController extends ChangeNotifier { required this.draftRevision, required this.clearDraft, this.replyTarget, + this.clearReply, required void Function(String) notice, required this.showNewest, required this.reviewIdentity, @@ -34,6 +35,7 @@ class SendController extends ChangeNotifier { final int Function() draftRevision; final void Function() clearDraft; final String? Function()? replyTarget; + final void Function()? clearReply; late final void Function(String) _notice; final void Function() showNewest; final Future Function(String, List) reviewIdentity; @@ -114,9 +116,9 @@ class SendController extends ChangeNotifier { await service.sendReply(chatId(), body, reply); } if (_disposed) return; - if (this.draftRevision() == draftRevision && - replyTarget?.call() == reply) { + if (this.draftRevision() == draftRevision) { clearDraft(); + if (replyTarget?.call() == reply) clearReply?.call(); } showNewest(); } on SendNotConfirmedException catch (e) { @@ -129,9 +131,9 @@ class SendController extends ChangeNotifier { _changed(); } // Edits made while the send was in flight survive: only an untouched draft is cleared. - if (this.draftRevision() == draftRevision && - replyTarget?.call() == reply) { + if (this.draftRevision() == draftRevision) { clearDraft(); + if (replyTarget?.call() == reply) clearReply?.call(); } showNewest(); } on IdentityChangedException catch (e) { diff --git a/lib/src/view/chat/chat_composer.dart b/lib/src/view/chat/chat_composer.dart index d6ec7d0..36a0890 100644 --- a/lib/src/view/chat/chat_composer.dart +++ b/lib/src/view/chat/chat_composer.dart @@ -25,8 +25,14 @@ class ChatComposer extends StatelessWidget { this.onInteraction, this.contextBar, this.focusNode, + this.submit, + this.editing = false, + this.editingBusy = false, + this.canSubmit = true, }); final Widget? contextBar; + final Future Function()? submit; + final bool editing, editingBusy, canSubmit; final FocusNode? focusNode; final double keyboardInset; final TextEditingController text; @@ -72,9 +78,9 @@ class ChatComposer extends StatelessWidget { child: CallbackShortcuts( bindings: { const SingleActivator(LogicalKeyboardKey.enter, control: true): - send.send, + submit ?? send.send, const SingleActivator(LogicalKeyboardKey.enter, meta: true): - send.send, + submit ?? send.send, }, child: Container( padding: const EdgeInsets.fromLTRB(12, 6, 8, 6), @@ -100,6 +106,7 @@ class ChatComposer extends StatelessWidget { child: TextField( key: Key('composer'), controller: text, + enabled: !editingBusy, focusNode: focusNode, onTap: onInteraction, inputFormatters: [MessageInputFormatter()], @@ -164,15 +171,18 @@ class ChatComposer extends StatelessWidget { .onPrimary, ), onPressed: - send.sending || + !canSubmit || + (editing ? editingBusy : send.sending) || text.text.trim().isEmpty || !hasSession ? null - : send.send, - tooltip: send.sending + : submit ?? send.send, + tooltip: editing + ? 'Save edit' + : send.sending ? 'Sending message' : 'Send message', - icon: send.sending + icon: (editing ? editingBusy : send.sending) ? SizedBox.square( dimension: 20, child: CircularProgressIndicator( @@ -182,7 +192,11 @@ class ChatComposer extends StatelessWidget { .onPrimary, ), ) - : Icon(Icons.arrow_upward_rounded), + : Icon( + editing + ? Icons.check_rounded + : Icons.arrow_upward_rounded, + ), ), ), ], diff --git a/lib/src/view/chat/chat_screen.dart b/lib/src/view/chat/chat_screen.dart index 1b355e0..f4583b2 100644 --- a/lib/src/view/chat/chat_screen.dart +++ b/lib/src/view/chat/chat_screen.dart @@ -1,3 +1,4 @@ +import 'package:fireplace/src/view/chat/quote_navigation.dart'; import 'package:fireplace/src/view/chat/composer/message_composer_context.dart'; import 'package:fireplace/src/model/chat/message_compose_target.dart'; import 'package:fireplace/src/view/chat/chat_unavailable.dart'; @@ -46,8 +47,7 @@ class _ChatScreenState extends ConsumerState { String? _ownerUid; late final RouteVisibility _routeVisibility; bool _showAll = false; - String? _locatedMessageId; - String? get _location => _locatedMessageId ?? widget.initialMessageId; + String? get _location => widget.initialMessageId; late final ChatDraftController _text; late final SendController _sendController; late final TimelineScroll _timeline; @@ -88,11 +88,11 @@ class _ChatScreenState extends ConsumerState { draft: () => _text.text, draftRevision: () => _text.revision, replyTarget: () => - ref.read(messageComposeProvider(widget.chatId))?.message.id, - clearDraft: () { - _text.clear(); - ref.read(messageComposeProvider(widget.chatId).notifier).clear(); - }, + ref.read(messageComposeProvider(widget.chatId))?.replyId, + clearDraft: _text.clear, + clearReply: () => ref + .read(messageComposeProvider(widget.chatId).notifier) + .clearReply(), notice: _snack, showNewest: () => _timeline.showNewestAfterOwnSend(_latest), reviewIdentity: (peerUid, pub) => @@ -453,40 +453,32 @@ class _ChatScreenState extends ConsumerState { ), onRetryHistory: () => ref.invalidate(messagesProvider(widget.chatId)), - onQuote: (id) { - if (!allMessages.any( - (m) => - m.id == id && - !m.remotelyDeleted && - m.status == MessageStatus.ok, - )) { - _snack( - 'The original message is no longer available on this device.', - ); - return; - } - setState(() { - _locatedMessageId = id; - _showAll = false; - }); - _timeline.showNewestAfterOwnSend( - () => _timeline.scrollToLatest(reduceMotion: true), - ); - }, + onQuote: (id) => locateQuotedMessage( + context, + _timeline, + allMessages, + id, + revealAll: () => setState(() => _showAll = true), + notice: _snack, + ), onLatest: _latest, ), ), MessageComposerContext( chatId: widget.chatId, name: name, - builder: (context, focus, bar) => ChatComposer( - focusNode: focus, - contextBar: bar, + builder: (context, composer) => ChatComposer( + focusNode: composer.focus, + contextBar: composer.bar, + submit: composer.submit, + editing: composer.editText != null, + editingBusy: composer.busy, + canSubmit: composer.canSubmit, onInteraction: () => ref .read(messageSelectionProvider(widget.chatId).notifier) .clear(), keyboardInset: keyboardInset, - text: _text, + text: composer.editText ?? _text, name: name, blocked: blocked, incomingRequest: incomingRequest, diff --git a/lib/src/view/chat/composer/message_composer_context.dart b/lib/src/view/chat/composer/message_composer_context.dart index 5f75fe7..72301a6 100644 --- a/lib/src/view/chat/composer/message_composer_context.dart +++ b/lib/src/view/chat/composer/message_composer_context.dart @@ -4,8 +4,25 @@ import 'package:fireplace/src/app/providers.dart'; import 'package:fireplace/src/db/message_quote.dart'; import 'package:fireplace/src/model/chat/message_compose_target.dart'; import 'package:fireplace/src/model/chat/message_limits.dart'; +import 'package:fireplace/src/model/chat/message_controls.dart'; import 'package:fireplace/src/view/chat/widgets/message_quote_preview.dart'; +class ComposerContextView { + const ComposerContextView({ + required this.focus, + this.bar, + this.editText, + this.submit, + this.busy = false, + this.canSubmit = true, + }); + final FocusNode focus; + final Widget? bar; + final TextEditingController? editText; + final Future Function()? submit; + final bool busy, canSubmit; +} + class MessageComposerContext extends ConsumerStatefulWidget { const MessageComposerContext({ super.key, @@ -14,7 +31,7 @@ class MessageComposerContext extends ConsumerStatefulWidget { required this.builder, }); final String chatId, name; - final Widget Function(BuildContext, FocusNode, Widget?) builder; + final Widget Function(BuildContext, ComposerContextView) builder; @override ConsumerState createState() => _MessageComposerContextState(); @@ -23,51 +40,126 @@ class MessageComposerContext extends ConsumerStatefulWidget { class _MessageComposerContextState extends ConsumerState { final _focus = FocusNode(); + final _edit = TextEditingController(); + @override + void initState() { + super.initState(); + _edit.addListener(_changed); + } + + void _changed() { + if (mounted) setState(() {}); + } + @override void dispose() { _focus.dispose(); + _edit.removeListener(_changed); + _edit.dispose(); super.dispose(); } + Future _submitEdit() async { + final provider = messageComposeProvider(widget.chatId); + final target = ref.read(provider); + if (target?.action != MessageComposeAction.edit || + _edit.text.trim().isEmpty || + _edit.text.trim() == target?.message.body) { + return; + } + final consumed = await ref + .read(messageControlsProvider(widget.chatId).notifier) + .edit(target!.message.id, _edit.text); + if (!mounted) return; + if (consumed && identical(ref.read(provider), target)) { + ref.read(provider.notifier).clear(); + } + } + @override Widget build(BuildContext context) { final provider = messageComposeProvider(widget.chatId); ref.listen(provider, (_, next) { + if (next?.action == MessageComposeAction.edit) { + _edit.value = TextEditingValue( + text: next!.message.body, + selection: TextSelection.collapsed(offset: next.message.body.length), + ); + } else { + _edit.clear(); + } if (next != null) _focus.requestFocus(); }); final target = ref.watch(provider); final uid = ref.watch(appSessionProvider.select((s) => s.value?.uid)); final messages = ref.watch(messagesProvider(widget.chatId)).value; final live = messages?.where((m) => m.id == target?.message.id).firstOrNull; + final editing = target?.action == MessageComposeAction.edit; + final busy = ref.watch( + messageControlsProvider(widget.chatId).select((s) => s.busy), + ); + if (editing && (live == null || live.remotelyDeleted)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && identical(ref.read(provider), target)) { + ref.read(provider.notifier).clear(); + } + }); + } return widget.builder( context, - _focus, - target == null - ? null - : Row( - key: const Key('replyComposerPreview'), - children: [ - Expanded( - child: MessageQuotePreview( - quote: MessageQuote( - targetId: target.message.id, - senderUid: target.message.senderUid, - body: clipMessage(live?.body ?? '', 1024), - unavailable: live == null || live.remotelyDeleted, - ), - author: target.message.senderUid == uid - ? 'You' - : widget.name, - ), - ), - IconButton( - key: const Key('cancelReply'), - tooltip: 'Cancel reply', - onPressed: () => ref.read(provider.notifier).clear(), - icon: const Icon(Icons.close), + ComposerContextView( + focus: _focus, + editText: editing ? _edit : null, + submit: editing ? _submitEdit : null, + busy: editing && busy, + canSubmit: + !editing || + (_edit.text.trim() != live?.body && + live != null && + !live.remotelyDeleted), + bar: target == null + ? null + : Row( + key: Key( + editing ? 'editComposerPreview' : 'replyComposerPreview', ), - ], - ), + children: [ + Expanded( + child: editing + ? const Padding( + padding: EdgeInsets.symmetric( + horizontal: 12, + vertical: 8, + ), + child: Row( + children: [ + Icon(Icons.edit_outlined, size: 18), + SizedBox(width: 8), + Flexible(child: Text('Edit message')), + ], + ), + ) + : MessageQuotePreview( + quote: MessageQuote( + targetId: target.message.id, + senderUid: target.message.senderUid, + body: clipMessage(live?.body ?? '', 1024), + unavailable: live == null || live.remotelyDeleted, + ), + author: target.message.senderUid == uid + ? 'You' + : widget.name, + ), + ), + IconButton( + key: Key(editing ? 'cancelEdit' : 'cancelReply'), + tooltip: editing ? 'Cancel edit' : 'Cancel reply', + onPressed: () => ref.read(provider.notifier).clear(), + icon: const Icon(Icons.close), + ), + ], + ), + ), ); } } diff --git a/lib/src/view/chat/message_edit_history.dart b/lib/src/view/chat/message_edit_history.dart new file mode 100644 index 0000000..87affe8 --- /dev/null +++ b/lib/src/view/chat/message_edit_history.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:fireplace/src/app/providers.dart'; +import 'package:fireplace/src/model/notifications/chat_activity.dart'; +import 'package:fireplace/src/model/chat/message_format.dart'; + +class MessageEditHistory extends ConsumerWidget { + const MessageEditHistory({ + super.key, + required this.chatId, + required this.messageId, + required this.ownerUid, + }); + final String chatId, messageId, ownerUid; + @override + Widget build(BuildContext context, WidgetRef ref) { + final owner = ref.watch(appSessionProvider).value; + final summary = ref.watch(chatSummaryProvider(chatId)); + ref.watch(blockedUidsProvider); + ref.watch(hiddenChatsProvider); + ref.watch(identityAlertsProvider); + final message = ref + .watch(messagesProvider(chatId)) + .value + ?.where((m) => m.id == messageId) + .firstOrNull; + final available = + owner?.uid == ownerUid && + summary != null && + ref.read(chatActivityProvider.notifier).eligible(summary) && + message != null && + !message.remotelyDeleted; + final edits = available + ? (message.edits.values.toList() + ..sort((a, b) => b.revision.compareTo(a.revision))) + : null; + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 24), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Edit history', + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 16), + if (!available) + const Text('This message is no longer available.') + else ...[ + for (final edit in edits!) ...[ + Text( + edit == edits.first + ? 'Current version' + : 'Previous version', + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 4), + SelectableText(displayMessage(edit.body).plain), + Text( + MaterialLocalizations.of(context).formatTimeOfDay( + TimeOfDay.fromDateTime(edit.sentAt.toLocal()), + ), + style: Theme.of(context).textTheme.bodySmall, + ), + const Divider(height: 24), + ], + Text( + 'Original message', + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 4), + SelectableText( + displayMessage(message.originalBody ?? message.body).plain, + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/lib/src/view/chat/message_menu.dart b/lib/src/view/chat/message_menu.dart index 21e96b4..3c94fee 100644 --- a/lib/src/view/chat/message_menu.dart +++ b/lib/src/view/chat/message_menu.dart @@ -102,6 +102,21 @@ List messageMenuActions( .reply(message), ), ), + if (canShare && + message.outgoing && + message.status == MessageStatus.ok && + message.editClock < 10 && + now.difference(message.sentAt) >= Duration.zero && + now.difference(message.sentAt) <= const Duration(hours: 24)) + MessageAction( + id: 'edit', + label: 'Edit', + icon: Icons.edit_outlined, + onSelected: () => run( + () => + ref.read(messageComposeProvider(chatId()).notifier).edit(message), + ), + ), if (canShare) MessageAction( id: 'copy', diff --git a/lib/src/view/chat/quote_navigation.dart b/lib/src/view/chat/quote_navigation.dart new file mode 100644 index 0000000..d645192 --- /dev/null +++ b/lib/src/view/chat/quote_navigation.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import 'package:fireplace/src/db/local_messages.dart'; +import 'package:fireplace/src/view/chat/timeline_scroll.dart'; + +Future locateQuotedMessage( + BuildContext context, + TimelineScroll timeline, + List messages, + String id, { + required VoidCallback revealAll, + required ValueChanged notice, +}) async { + if (!messages.any( + (m) => m.id == id && !m.remotelyDeleted && m.status == MessageStatus.ok, + )) { + notice('The original message is no longer available on this device.'); + return; + } + final reduceMotion = MediaQuery.disableAnimationsOf(context); + revealAll(); + await WidgetsBinding.instance.endOfFrame; + if (!context.mounted) return; + final found = await timeline.locate( + id, + messages.map((m) => m.id).toList(), + reduceMotion: reduceMotion, + ); + if (context.mounted && !found) { + notice( + 'Could not locate the original message. Try searching this conversation.', + ); + } +} diff --git a/lib/src/view/chat/timeline_scroll.dart b/lib/src/view/chat/timeline_scroll.dart index 35640fc..7fbfa02 100644 --- a/lib/src/view/chat/timeline_scroll.dart +++ b/lib/src/view/chat/timeline_scroll.dart @@ -86,6 +86,51 @@ class TimelineScroll extends ChangeNotifier { _anchors.removeWhere((id, _) => !visibleIds.contains(id)); } + /// Locate a lazily built row without walking an unbounded history one screen + /// at a time. Built row indices bracket the next scroll offset. + Future locate( + String id, + List orderedIds, { + required bool reduceMotion, + }) async { + final indices = {for (final (index, key) in orderedIds.indexed) key: index}; + final target = indices[id]; + if (target == null) return false; + double lower = 0; + double? upper; + for (var attempt = 0; attempt < 24; attempt++) { + if (_disposed || !isMounted() || !controller.hasClients) return false; + final context = _anchors[id]?.currentContext; + if (context != null && context.mounted) { + await Scrollable.ensureVisible( + context, + alignment: .5, + duration: reduceMotion + ? Duration.zero + : const Duration(milliseconds: 180), + ); + return true; + } + final built = [ + for (final entry in _anchors.entries) + if (entry.value.currentContext != null && + indices.containsKey(entry.key)) + indices[entry.key]!, + ]; + if (built.isEmpty) return false; + final oldest = built.reduce((a, b) => a < b ? a : b); + if (target < oldest) { + lower = controller.offset; + } else { + upper = controller.offset; + } + final next = (lower + (upper ?? controller.position.maxScrollExtent)) / 2; + controller.jumpTo(next.clamp(0, controller.position.maxScrollExtent)); + await WidgetsBinding.instance.endOfFrame; + } + return false; + } + GlobalKey anchorFor(String messageId) => _anchors.putIfAbsent(messageId, GlobalKey.new); diff --git a/lib/src/view/chat/widgets/message_bubble.dart b/lib/src/view/chat/widgets/message_bubble.dart index 177af09..56147d3 100644 --- a/lib/src/view/chat/widgets/message_bubble.dart +++ b/lib/src/view/chat/widgets/message_bubble.dart @@ -1,5 +1,6 @@ import 'package:fireplace/src/view/chat/widgets/message_quote_preview.dart'; import 'package:flutter/material.dart'; +import 'package:fireplace/src/view/chat/message_edit_history.dart'; import 'package:flutter/services.dart'; import 'package:flutter/semantics.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -203,6 +204,12 @@ class _MessageBubbleState extends ConsumerState { child: GestureDetector( key: _actionAnchor, onTap: widget.selecting ? widget.onToggleSelection : null, + onDoubleTap: widget.selecting || widget.preview + ? null + : widget.actions + .where((a) => a.id == 'edit') + .firstOrNull + ?.onSelected, onHorizontalDragUpdate: reply == null ? null : (d) => setState( @@ -344,13 +351,61 @@ class _MessageBubbleState extends ConsumerState { ), ), const SizedBox(height: 2), - Text( - time, - style: TextStyle( - color: mine ? fg : t.secondaryText, - fontSize: 11, - height: 1.3, - ), + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: 4, + children: [ + if (message.edits.isNotEmpty && + !message.remotelyDeleted) + Semantics( + button: true, + label: 'View edit history', + child: GestureDetector( + key: ValueKey('edited-${message.id}'), + behavior: HitTestBehavior.opaque, + onTap: widget.preview + ? null + : widget.selecting + ? widget.onToggleSelection + : () => showModalBottomSheet( + context: context, + useSafeArea: true, + showDragHandle: true, + builder: (_) => MessageEditHistory( + chatId: message.chatId, + messageId: message.id, + ownerUid: + ref + .read(appSessionProvider) + .value + ?.uid ?? + '', + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: 4, + ), + child: Text( + 'Edited', + style: TextStyle( + color: mine ? fg : t.secondaryText, + fontSize: 11, + height: 1.3, + ), + ), + ), + ), + ), + Text( + time, + style: TextStyle( + color: mine ? fg : t.secondaryText, + fontSize: 11, + height: 1.3, + ), + ), + ], ), ], ), diff --git a/scripts/render/README.md b/scripts/render/README.md index ec733d5..e48aa2b 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 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, +The catalog has 84 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/scripts/render/reply_edit_cases.dart b/scripts/render/reply_edit_cases.dart index c02ad69..8d0d779 100644 --- a/scripts/render/reply_edit_cases.dart +++ b/scripts/render/reply_edit_cases.dart @@ -5,6 +5,40 @@ import 'package:fireplace/src/view/chat/chat_screen.dart'; import 'catalog_case.dart'; final replyEditCases = [ + for (final state in [ + 'edit-composing', + 'edit-saved', + 'edit-history', + 'edit-large', + ]) + CatalogCase( + 'messages-$state', + const ChatScreen(chatId: 'alice_fred'), + size: state.endsWith('large') + ? const Size(320, 640) + : const Size(390, 844), + textScale: state.endsWith('large') ? 2 : 1, + act: (t, f) async { + await t.longPress(find.byKey(const ValueKey('messageBubble-4'))); + await t.pumpAndSettle(); + await t.ensureVisible(find.byKey(const ValueKey('messageAction-edit'))); + await t.tap(find.byKey(const ValueKey('messageAction-edit'))); + await t.pumpAndSettle(); + await t.enterText( + find.byKey(const Key('composer')), + 'Looking forward to it. See you at three.', + ); + await t.pump(); + if (state == 'edit-composing' || state == 'edit-large') return; + await t.tap(find.byKey(const Key('send'))); + await t.pumpAndSettle(); + if (state == 'edit-history') { + await t.tap(find.byKey(const ValueKey('edited-4'))); + await t.pump(const Duration(milliseconds: 350)); + await t.pumpAndSettle(); + } + }, + ), for (final state in [ 'reply-composing', 'reply-sent', diff --git a/test/db/message_edits_test.dart b/test/db/message_edits_test.dart new file mode 100644 index 0000000..a58126d --- /dev/null +++ b/test/db/message_edits_test.dart @@ -0,0 +1,144 @@ +import 'dart:io'; +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fireplace/fireplace_services.dart'; +import 'package:fireplace/src/db/message_mutation.dart'; + +import '../support/ui_fixture.dart'; + +LocalMessage revision( + int number, { + String actor = 'fred', + String device = 'a', + MessageStatus status = MessageStatus.ok, +}) => LocalMessage( + id: 'edit-$number-$actor-$device', + chatId: 'alice_fred', + senderUid: actor, + senderDevice: device, + outgoing: actor == 'alice', + sentAt: fixtureTime.add(Duration(minutes: number)), + body: '', + status: status, + mutation: MessageMutation( + kind: MessageMutationKind.edit, + targetId: 'original', + body: 'Version $number $device', + revision: number, + ), +); + +void main() { + test('bounded revisions reject malformed edits', () { + for (final body in ['', ' ', 'a' * 16385]) { + expect( + () => MessageMutation.fromJson({ + 'type': 'edit', + 'targetId': 'a', + 'body': body, + 'revision': 1, + }), + throwsFormatException, + ); + } + for (final number in [0, 11, '1']) { + expect( + () => MessageMutation.fromJson({ + 'type': 'edit', + 'targetId': 'a', + 'body': 'Text', + 'revision': number, + }), + throwsFormatException, + ); + } + }); + test( + 'unknown targets, random order, duplicates and linked ties converge', + () async { + for (var seed = 0; seed < 30; seed++) { + final store = MemoryMessageStore(); + final events = [ + message(id: 'original', body: 'Original'), + revision(1), + revision(2), + revision(2, device: 'z'), + revision(3, actor: 'alice'), + ]; + events.shuffle(Random(seed)); + for (final event in events) { + await store.add(event); + } + await store.add(revision(1)); + final result = (await store.watch('alice_fred').first).single; + expect(result.body, 'Version 2 z'); + expect(result.originalBody, 'Original'); + expect(result.edits.keys.toSet(), {1, 2}); + expect(result.editClock, 2); + } + }, + ); + test( + 'unconfirmed edits reserve a revision but never change visible text', + () async { + final store = MemoryMessageStore(); + await store.add(message(id: 'original', body: 'Original')); + await store.add(revision(1, status: MessageStatus.unconfirmed)); + var result = (await store.watch('alice_fred').first).single; + expect(result.body, 'Original'); + expect(result.editClock, 1); + expect(result.edits, isEmpty); + await store.add(revision(1)); + result = (await store.watch('alice_fred').first).single; + expect(result.edits.length, 1); + }, + ); + test( + 'encrypted reopen keeps history; remote and local deletion erase it', + () async { + final dir = Directory.systemTemp.createTempSync('fireplace_edits'); + addTearDown(() => dir.deleteSync(recursive: true)); + final secrets = MemorySecretStore(); + var store = await EncryptedFileMessageStore.open( + dir: dir, + secrets: secrets, + uid: 'alice', + ); + await store.add(message(id: 'original', body: 'Original')); + await store.add(revision(1)); + await store.close(); + store = await EncryptedFileMessageStore.open( + dir: dir, + secrets: secrets, + uid: 'alice', + ); + expect((await store.watch('alice_fred').first).single.edits.length, 1); + await store.add( + LocalMessage( + id: 'delete', + chatId: 'alice_fred', + senderUid: 'fred', + senderDevice: 'a', + outgoing: false, + sentAt: fixtureTime, + body: '', + mutation: const MessageMutation( + kind: MessageMutationKind.delete, + targetId: 'original', + ), + ), + ); + await store.add(revision(2)); + final result = (await store.watch('alice_fred').first).single; + expect(result.body, isEmpty); + expect(result.originalBody, isEmpty); + expect(result.edits, isEmpty); + expect(await store.get('alice_fred', revision(1).id), isNull); + await store.remove('alice_fred', 'original'); + await store.add(revision(3)); + expect(await store.watch('alice_fred').first, isEmpty); + await store.close(); + }, + ); +} diff --git a/test/model/chat/message_edit_failure_test.dart b/test/model/chat/message_edit_failure_test.dart new file mode 100644 index 0000000..943973f --- /dev/null +++ b/test/model/chat/message_edit_failure_test.dart @@ -0,0 +1,81 @@ +import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; +import 'package:fireplace/fireplace_services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'durability_test.dart' show Phone, fb, eventually; + +void main() { + for (final committed in [false, true]) { + test( + 'unknown edit outcome (committed=$committed) never publishes twice', + () async { + final db = FakeFirebaseFirestore(); + for (final uid in ['alice', 'bob']) { + await db.collection('users').doc(uid).set({'username': uid}); + await db.collection('usernames').doc(uid).set({'uid': uid}); + } + final alice = await Phone.create(db, 'alice'); + final bob = await Phone.create(db, 'bob'); + addTearDown(() async { + await alice.chat.close(); + await bob.chat.close(); + }); + const chat = 'alice_bob'; + await alice.chat.startChat('bob'); + await bob.chat.acceptRequest(chat); + await alice.chat.sendText(chat, 'first'); + final id = (await alice.messages.watch(chat).first).single.id; + final sessionBefore = await alice.secrets.read(alice.sessKey(bob)); + alice.commit = (batch) async { + if (committed) await batch.commit(); + throw fb('unavailable'); + }; + SendNotConfirmedException? pending; + try { + await alice.chat.editMessage(chat, id, 'Corrected'); + } on SendNotConfirmedException catch (e) { + pending = e; + } + expect(pending, isNotNull); + expect(pending!.mutation, isNotNull); + expect((await alice.messages.watch(chat).first).single.edits, isEmpty); + expect( + await alice.secrets.read(alice.sessKey(bob)), + isNot(sessionBefore), + ); + alice.commit = null; + final outcome = await alice.chat.checkSendStatus( + chat, + pending.messageId, + ); + expect( + outcome, + committed ? SendOutcome.confirmed : SendOutcome.publishUnknown, + ); + expect( + (await db.collection('chats/$chat/messages').get()).docs.length, + committed ? 2 : 1, + ); + if (committed) { + expect( + (await alice.messages.watch(chat).first).single.body, + 'Corrected', + ); + } + await alice.chat.sendText(chat, 'after control'); + final sub = bob.chat.startSync(chat); + addTearDown(sub.cancel); + await eventually( + () async => (await bob.bodies(chat)).contains('after control'), + 'ratchet continues', + ); + expect( + (await bob.messages.watch(chat).first).every( + (m) => m.status == MessageStatus.ok, + ), + isTrue, + ); + }, + ); + } +} diff --git a/test/model/chat/message_editing_test.dart b/test/model/chat/message_editing_test.dart new file mode 100644 index 0000000..45a16ba --- /dev/null +++ b/test/model/chat/message_editing_test.dart @@ -0,0 +1,122 @@ +import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fireplace/src/model/chat/chat_service.dart'; +import 'package:fireplace/src/model/chat/message_content.dart'; +import 'package:fireplace/src/db/local_messages.dart'; +import 'package:fireplace/src/db/message_mutation.dart'; + +import 'chat_service_test.dart' show Dev, addUser, eventually; + +void main() { + test('author edits and history reach both people and linked devices with reactions intact', () async { + final db = FakeFirebaseFirestore(); + await addUser(db, 'alice', 'alice'); + await addUser(db, 'bob', 'bob'); + final alice = await Dev.create(db, 'alice'), + bob = await Dev.create(db, 'bob'); + final linked = await Dev.linked(db, alice); + addTearDown(() async { + for (final d in [alice, bob, linked]) { + await d.close(); + await d.chat.close(); + } + }); + const chat = 'alice_bob'; + await db.collection('chats').doc(chat).set({ + 'members': ['alice', 'bob'], + 'accepted': true, + }); + for (final d in [alice, bob, linked]) { + d.sync(chat); + } + await alice.chat.sendText(chat, 'Meet on Monday'); + await eventually( + () async => + (await linked.history(chat)).isNotEmpty && + (await bob.history(chat)).isNotEmpty, + ); + final id = (await alice.history(chat)).single.id; + await bob.chat.react(chat, id, '👍'); + await alice.chat.editMessage(chat, id, 'Meet on Tuesday'); + await eventually( + () async => (await linked.history(chat)).single.body == 'Meet on Tuesday', + ); + await linked.chat.editMessage(chat, id, 'Meet on Wednesday'); + await eventually( + () async => + (await bob.history(chat)).single.edits.length == 2 && + (await alice.history(chat)).single.edits.length == 2, + ); + for (final d in [alice, bob, linked]) { + final result = (await d.history(chat)).single; + expect(result.body, 'Meet on Wednesday'); + expect(result.originalBody, 'Meet on Monday'); + expect(result.reactions['bob']!.emoji, '👍'); + } + await expectLater( + bob.chat.editMessage(chat, id, 'Forged'), + throwsA(isA()), + ); + expect(db.dump(), isNot(contains('Meet on'))); + for (final doc + in (await db.collection('chats/$chat/messages').get()).docs) { + expect(doc.data().keys.toSet(), { + 'senderUid', + 'senderDevice', + 'ts', + 'envelopes', + 'expireAt', + }); + } + }); + test( + 'sender enforces age, ten revisions, changed body and Unicode limit', + () async { + final store = MemoryMessageStore(); + for (final (id, age, clock) in [ + ('old', const Duration(hours: 25), 0), + ('full', Duration.zero, 10), + ('ok', Duration.zero, 0), + ]) { + await store.add( + LocalMessage( + id: id, + chatId: 'alice_bob', + senderUid: 'alice', + senderDevice: 'a', + outgoing: true, + sentAt: DateTime.now().subtract(age), + body: 'Original', + editClock: clock, + ), + ); + } + Future prepare(String id, String body) => prepareMessageContent( + messages: store, + uid: 'alice', + chatId: 'alice_bob', + text: '', + mutation: MessageMutation( + kind: MessageMutationKind.edit, + targetId: id, + body: body, + ), + ); + for (final id in ['old', 'full']) { + await expectLater( + prepare(id, 'Changed'), + throwsA(isA()), + ); + } + await expectLater( + prepare('ok', 'Original'), + throwsA(isA()), + ); + await expectLater( + prepare('ok', '😀' * 16385), + throwsA(isA()), + ); + await prepare('ok', '😀' * 16384); + }, + ); +} diff --git a/test/support/ui_fixture.dart b/test/support/ui_fixture.dart index 3e20a4c..f49eae6 100644 --- a/test/support/ui_fixture.dart +++ b/test/support/ui_fixture.dart @@ -80,6 +80,32 @@ class FixtureChat extends Fake implements ChatService { if (sendError != null) throw sendError!; } + final edits = <(String, String)>[]; + @override + Future editMessage(String chatId, String targetId, String body) async { + controlAttempts++; + if (controlError != null) throw controlError!; + final target = await store.get(chatId, targetId); + edits.add((targetId, body)); + await store.add( + LocalMessage( + id: 'edit-${edits.length}', + chatId: chatId, + senderUid: 'alice', + senderDevice: 'example-device-alice', + outgoing: true, + sentAt: fixtureTime, + body: '', + mutation: MessageMutation( + kind: MessageMutationKind.edit, + targetId: targetId, + body: body, + revision: target!.editClock + 1, + ), + ), + ); + } + final replies = <(String, String)>[]; @override Future sendReply(String chatId, String body, String targetId) async { diff --git a/test/view/chat/message_editing_test.dart b/test/view/chat/message_editing_test.dart new file mode 100644 index 0000000..afc145c --- /dev/null +++ b/test/view/chat/message_editing_test.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; + +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:fireplace/src/model/chat/chat_service.dart'; +import 'package:fireplace/src/db/message_mutation.dart'; + +import '../../support/ui_fixture.dart'; +import 'long_press_menu_test.dart' show openChat, bubble, action; + +Future edit(WidgetTester t) async { + await t.longPress(bubble('4')); + await t.pumpAndSettle(); + await t.ensureVisible(action('edit')); + await t.tap(action('edit')); + await t.pumpAndSettle(); +} + +void main() { + testWidgets( + 'a finishing send clears its own draft without cancelling an edit', + (t) async { + final f = UiFixture(); + await f.seed(); + addTearDown(f.session.close); + await openChat(t, f); + f.chat.sendHold = Completer(); + await t.enterText( + find.byKey(const Key('composer')), + 'Sending ordinary text', + ); + await t.pump(); + await t.tap(find.byKey(const Key('send'))); + await t.pump(); + // The pending send spinner is deliberately still animating. + await t.longPress(bubble('4')); + for (var frame = 0; frame < 6; frame++) { + await t.pump(const Duration(milliseconds: 100)); + } + await t.ensureVisible(action('edit')); + await t.tap(action('edit')); + for (var frame = 0; frame < 6; frame++) { + await t.pump(const Duration(milliseconds: 100)); + } + expect(find.byKey(const Key('editComposerPreview')), findsOneWidget); + await t.enterText( + find.byKey(const Key('composer')), + 'An independent correction', + ); + f.chat.sendHold!.complete(); + await t.pumpAndSettle(); + expect(find.byKey(const Key('editComposerPreview')), findsOneWidget); + expect(find.text('An independent correction'), findsOneWidget); + await t.tap(find.byKey(const Key('cancelEdit'))); + await t.pumpAndSettle(); + expect( + t.widget(find.byKey(const Key('composer'))).controller!.text, + isEmpty, + ); + }, + ); + testWidgets( + 'editing has a separate draft and cancel restores ordinary composition', + (t) async { + final f = UiFixture(); + await f.seed(); + addTearDown(f.session.close); + await openChat(t, f); + await t.enterText(find.byKey(const Key('composer')), 'Unsent reply'); + await t.pump(); + await edit(t); + expect(find.byKey(const Key('editComposerPreview')), findsOneWidget); + expect( + t.widget(find.byKey(const Key('composer'))).controller!.text, + contains('Looking forward'), + ); + await t.enterText(find.byKey(const Key('composer')), 'Changed'); + await t.tap(find.byKey(const Key('cancelEdit'))); + await t.pumpAndSettle(); + expect(find.text('Unsent reply'), findsOneWidget); + expect(f.chat.edits, isEmpty); + }, + ); + testWidgets('double tap, keyboard save, Edited history and live deletion', ( + t, + ) async { + final f = UiFixture(); + await f.seed(); + addTearDown(f.session.close); + await openChat(t, f); + await t.tap(bubble('4')); + await t.pump(const Duration(milliseconds: 80)); + await t.tap(bubble('4')); + await t.pumpAndSettle(); + expect(find.byKey(const Key('editComposerPreview')), findsOneWidget); + expect( + t.widget(find.byKey(const Key('send'))).onPressed, + isNull, + ); + await t.enterText(find.byKey(const Key('composer')), 'See you at three.'); + await t.pump(); + await t.sendKeyDownEvent(LogicalKeyboardKey.controlLeft); + await t.sendKeyEvent(LogicalKeyboardKey.enter); + await t.sendKeyUpEvent(LogicalKeyboardKey.controlLeft); + await t.pumpAndSettle(); + expect(f.chat.edits, [('4', 'See you at three.')]); + expect(find.byKey(const Key('editComposerPreview')), findsNothing); + await t.tap(find.byKey(const ValueKey('edited-4'))); + await t.pump(const Duration(milliseconds: 350)); + await t.pumpAndSettle(); + expect(find.text('Edit history'), findsOneWidget); + expect(find.text('Original message'), findsOneWidget); + expect(find.text('Current version'), findsOneWidget); + await f.chat.deleteForEveryone('alice_fred', '4'); + await t.pumpAndSettle(); + expect(find.text('This message is no longer available.'), findsOneWidget); + expect(find.text('Current version'), findsNothing); + }); + testWidgets('uncertain edit leaves explicit recovery and does not retry', ( + t, + ) async { + final f = UiFixture(); + await f.seed(); + addTearDown(f.session.close); + await openChat(t, f); + f.chat.controlError = SendNotConfirmedException( + outcome: SendOutcome.publishUnknown, + chatId: 'alice_fred', + messageId: 'edit-pending', + body: '', + attemptedAt: fixtureTime, + persisted: true, + mutation: const MessageMutation( + kind: MessageMutationKind.edit, + targetId: '4', + body: 'Changed', + revision: 1, + ), + ); + await edit(t); + await t.enterText(find.byKey(const Key('composer')), 'Changed'); + await t.pump(); + await t.tap(find.byKey(const Key('send'))); + await t.pumpAndSettle(); + expect(find.byKey(const Key('editComposerPreview')), findsNothing); + expect(find.text('Check status'), findsOneWidget); + expect(f.chat.controlAttempts, 1); + }); + testWidgets('received and exhausted messages do not offer editing', ( + t, + ) async { + final f = UiFixture(); + await f.seed(); + addTearDown(f.session.close); + final own = await f.chat.store.get('alice_fred', '4'); + await f.chat.store.add(own!.copyWith(editClock: 10)); + await openChat(t, f); + for (final id in ['4', '5']) { + await t.longPress(bubble(id)); + await t.pumpAndSettle(); + expect(action('edit'), findsNothing); + await t.sendKeyEvent(LogicalKeyboardKey.escape); + await t.pumpAndSettle(); + } + }); +} diff --git a/test/view/chat/message_menu_test.dart b/test/view/chat/message_menu_test.dart index 8bcad1b..7634fd3 100644 --- a/test/view/chat/message_menu_test.dart +++ b/test/view/chat/message_menu_test.dart @@ -34,6 +34,7 @@ void main() { 'Hello', [ 'reply', + 'edit', 'copy', 'forward', 'selectText', @@ -142,6 +143,7 @@ void main() { expect(actions!.map((a) => a.id).toList(), expected); const labels = { 'reply': 'Reply', + 'edit': 'Edit', 'copy': 'Copy', 'forward': 'Forward', 'selectText': 'Select text', diff --git a/test/view/chat/quoted_reply_test.dart b/test/view/chat/quoted_reply_test.dart index 535ee3e..482214d 100644 --- a/test/view/chat/quoted_reply_test.dart +++ b/test/view/chat/quoted_reply_test.dart @@ -2,11 +2,48 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:fireplace/src/app/providers.dart'; +import 'package:fireplace/src/db/message_quote.dart'; import '../../support/ui_fixture.dart'; import 'long_press_menu_test.dart' show openChat, bubble, action; void main() { + testWidgets( + 'quote navigation locates a lazy row without hiding newer messages', + (t) async { + final f = UiFixture(); + await f.seed(); + addTearDown(f.session.close); + for (var i = 0; i < 150; i++) { + await f.chat.store.add( + message( + id: 'extra-$i', + body: 'Fictional message $i', + at: fixtureTime.add(Duration(minutes: i + 1)), + ), + ); + } + await f.chat.store.add( + message( + id: 'last-reply', + body: 'My reply', + at: fixtureTime.add(const Duration(days: 1)), + ).copyWith( + quote: const MessageQuote( + targetId: 'extra-30', + senderUid: 'fred', + body: 'Fictional message 30', + ), + ), + ); + await openChat(t, f); + await t.tap(find.byKey(const ValueKey('messageQuote-last-reply'))); + await t.pumpAndSettle(); + expect(bubble('extra-30'), findsOneWidget); + expect(find.byKey(const Key('searchLocation')), findsNothing); + expect(find.textContaining('Could not locate'), findsNothing); + }, + ); testWidgets('reply preview cancels without changing the ordinary draft', ( t, ) async { @@ -53,7 +90,7 @@ void main() { await t.tap(quote); await t.pumpAndSettle(); expect(bubble('5'), findsOneWidget); - expect(bubble('reply-1'), findsNothing); + expect(find.byKey(const Key('searchLocation')), findsNothing); }, ); testWidgets(