From ada9f722f5a8587ac3f818e2c9a39aae78948755 Mon Sep 17 00:00:00 2001 From: Evan Morikawa Date: Mon, 3 Aug 2015 17:19:07 -0700 Subject: [PATCH] feat(message-list): better sending state Summary: We now have a `MessageItemContainer` class that handles the logic of deciding what kind of message to show. We introduce a new `PendingMessage` (which is just a sublcass of `MessageItem`) that has the spinner and stuff. Also tests Test Plan: edgehill --test Reviewers: bengotow Reviewed By: bengotow Differential Revision: https://phab.nylas.com/D1833 --- .../composer/lib/composer-view.cjsx | 22 +---- .../composer/spec/composer-view-spec.cjsx | 27 ------ .../composer/stylesheets/composer.less | 7 -- .../lib/message-item-container.cjsx | 85 ++++++++++++++++++ .../message-list/lib/message-item.cjsx | 16 +++- .../message-list/lib/message-list.cjsx | 58 ++++++------ .../lib/pending-message-item.cjsx | 25 ++++++ .../spec/message-item-container-spec.cjsx | 58 ++++++++++++ .../message-list/spec/message-list-spec.cjsx | 26 ++++-- src/flux/stores/draft-store.coffee | 3 +- src/flux/tasks/send-draft.coffee | 2 + static/images/composer/sending-spinner.gif | Bin 0 -> 2545 bytes 12 files changed, 234 insertions(+), 95 deletions(-) create mode 100644 internal_packages/message-list/lib/message-item-container.cjsx create mode 100644 internal_packages/message-list/lib/pending-message-item.cjsx create mode 100644 internal_packages/message-list/spec/message-item-container-spec.cjsx create mode 100644 static/images/composer/sending-spinner.gif diff --git a/internal_packages/composer/lib/composer-view.cjsx b/internal_packages/composer/lib/composer-view.cjsx index f8de4877e..5b9ef621e 100644 --- a/internal_packages/composer/lib/composer-view.cjsx +++ b/internal_packages/composer/lib/composer-view.cjsx @@ -60,7 +60,6 @@ class ComposerView extends React.Component showbcc: false showsubject: false showQuotedText: false - isSending: DraftStore.isSendingDraft(@props.localId) uploads: FileUploadStore.uploadsForMessage(@props.localId) ? [] componentWillMount: => @@ -71,7 +70,6 @@ class ComposerView extends React.Component not Utils.isEqualReact(nextState, @state) componentDidMount: => - @_draftStoreUnlisten = DraftStore.listen @_onSendingStateChanged @_uploadUnlisten = FileUploadStore.listen @_onFileUploadStoreChange @_keymapUnlisten = atom.commands.add '.composer-outer-wrap', { 'composer:show-and-focus-bcc': @_showAndFocusBcc @@ -92,7 +90,6 @@ class ComposerView extends React.Component @_teardownForDraft() @_deleteDraftIfEmpty() @_uploadUnlisten() if @_uploadUnlisten - @_draftStoreUnlisten() if @_draftStoreUnlisten @_keymapUnlisten.dispose() if @_keymapUnlisten componentDidUpdate: => @@ -165,7 +162,6 @@ class ComposerView extends React.Component shouldAcceptDrop={@_shouldAcceptDrop} onDragStateChange={ ({isDropping}) => @setState({isDropping}) } onDrop={@_onDrop}> -
@@ -572,10 +568,10 @@ class ComposerView extends React.Component _sendDraft: (options = {}) => return unless @_proxy - # We need to check the `DraftStore` instead of `@state.isSending` - # because the `DraftStore` is immediately and synchronously updated as - # soon as this function fires. Since `setState` is asynchronous, if we - # used that as our only check, then we might get a false reading. + # We need to check the `DraftStore` because the `DraftStore` is + # immediately and synchronously updated as soon as this function + # fires. Since `setState` is asynchronous, if we used that as our only + # check, then we might get a false reading. return if DraftStore.isSendingDraft(@props.localId) draft = @_proxy.draft() @@ -629,11 +625,6 @@ class ComposerView extends React.Component @_sendDraft({force: true}) return - # There can be a delay between when the send request gets initiated - # by a user and when the draft is prepared on on the TaskQueue, which - # is how we detect that the draft is sending. - @setState(isSending: true) - Actions.sendDraft(@props.localId) _mentionsAttachment: (body) => @@ -654,11 +645,6 @@ class ComposerView extends React.Component @setState {showcc: true} @focus('textFieldCc') - _onSendingStateChanged: => - isSending = DraftStore.isSendingDraft(@props.localId) - if isSending isnt @state.isSending - @setState({isSending}) - _onEmptyCc: => @setState showcc: false @focus('textFieldTo') diff --git a/internal_packages/composer/spec/composer-view-spec.cjsx b/internal_packages/composer/spec/composer-view-spec.cjsx index 5793b7fd6..036023a82 100644 --- a/internal_packages/composer/spec/composer-view-spec.cjsx +++ b/internal_packages/composer/spec/composer-view-spec.cjsx @@ -473,33 +473,6 @@ describe "populated composer", -> expect(Actions.sendDraft).toHaveBeenCalledWith(DRAFT_LOCAL_ID) expect(Actions.sendDraft.calls.length).toBe 1 - it "disables the composer once sending has started", -> - useFullDraft.apply(@); makeComposer.call(@) - sendBtn = React.findDOMNode(@composer.refs.sendButton) - cover = ReactTestUtils.findRenderedDOMComponentWithClass(@composer, "composer-cover") - expect(React.findDOMNode(cover).style.display).toBe "none" - ReactTestUtils.Simulate.click sendBtn - @isSending.state = true - DraftStore.trigger() - expect(React.findDOMNode(cover).style.display).toBe "block" - expect(@composer.state.isSending).toBe true - - it "re-enables the composer if sending threw an error", -> - @isSending.state = null - useFullDraft.apply(@); makeComposer.call(@) - sendBtn = React.findDOMNode(@composer.refs.sendButton) - ReactTestUtils.Simulate.click sendBtn - - @isSending.state = true - DraftStore.trigger() - - expect(@composer.state.isSending).toBe true - - @isSending.state = false - DraftStore.trigger() - - expect(@composer.state.isSending).toBe false - describe "when sending a message with keyboard inputs", -> beforeEach -> useFullDraft.apply(@) diff --git a/internal_packages/composer/stylesheets/composer.less b/internal_packages/composer/stylesheets/composer.less index 6fc4547da..a1522c7ca 100644 --- a/internal_packages/composer/stylesheets/composer.less +++ b/internal_packages/composer/stylesheets/composer.less @@ -15,13 +15,6 @@ display: flex; flex-direction: column; - .composer-cover { - position: absolute; - top: -1 * @spacing-double; right: 0; bottom: 0; left: 0; - z-index: 1000; - background: rgba(255,255,255,0.7); - } - .composer-drop-cover { position: absolute; top: 0; right: 0; bottom: 0; left: 0; diff --git a/internal_packages/message-list/lib/message-item-container.cjsx b/internal_packages/message-list/lib/message-item-container.cjsx new file mode 100644 index 000000000..4802cdb8d --- /dev/null +++ b/internal_packages/message-list/lib/message-item-container.cjsx @@ -0,0 +1,85 @@ +React = require 'react' +classNames = require 'classnames' + +MessageItem = require './message-item' +PendingMessageItem = require './pending-message-item' + +{DraftStore, + MessageStore} = require 'nylas-exports' + +{InjectedComponent} = require 'nylas-component-kit' + +class MessageItemContainer extends React.Component + @displayName = 'MessageItemContainer' + + @propTypes = + thread: React.PropTypes.object.isRequired + message: React.PropTypes.object.isRequired + + # The messageId (in the case of draft's local ID) is a derived + # property that only the parent MessageList knows about. + messageId: React.PropTypes.string + + collapsed: React.PropTypes.bool + isLastMsg: React.PropTypes.bool + isBeforeReplyArea: React.PropTypes.bool + onRequestScrollTo: React.PropTypes.func + + constructor: (@props) -> + @state = @_getStateFromStores() + + componentWillReceiveProps: (newProps) -> + @setState(@_getStateFromStores()) + + componentDidMount: => + if @props.message?.draft + @unlisten = DraftStore.listen @_onSendingStateChanged + + componentWillUnmount: => + @unlisten() if @unlisten + + focus: => @refs.message.focus?() + + render: => + if @props.message.draft + if @state.isSending + @_renderMessage(PendingMessageItem) + else + @_renderComposer() + else + @_renderMessage(MessageItem) + + _renderMessage: (component) => + + + _renderComposer: => + props = + mode: "inline" + localId: @props.messageId + threadId: @props.thread.id + onRequestScrollTo: @props.onRequestScrollTo + + + + _classNames: => classNames + "draft": @props.message.draft + "unread": @props.message.unread + "collapsed": @props.collapsed + "message-item-wrap": true + "before-reply-area": @props.isBeforeReplyArea + + _onSendingStateChanged: (draftLocalId) => + @setState(@_getStateFromStores()) if draftLocalId is @props.messageId + + _getStateFromStores: -> + isSending: DraftStore.isSendingDraft(@props.messageId) + +module.exports = MessageItemContainer diff --git a/internal_packages/message-list/lib/message-item.cjsx b/internal_packages/message-list/lib/message-item.cjsx index e0eb48c50..b91f06012 100644 --- a/internal_packages/message-list/lib/message-item.cjsx +++ b/internal_packages/message-list/lib/message-item.cjsx @@ -92,12 +92,14 @@ class MessageItem extends React.Component _renderHeader: =>
+ {@_renderHeaderSideItems()} +
- + {@_renderMessageControls()} {@_renderFolder()} - {@_renderHeaderExpansionControl()} + {@_renderHeaderDetailToggle()}
+ _renderMessageControls: -> + + _renderFolder: => return [] unless @state.detailedHeaders and @props.message.folder
@@ -125,7 +130,6 @@ class MessageItem extends React.Component
{@props.message.folder.displayName}
- _onClickParticipants: (e) => el = e.target while el isnt e.currentTarget @@ -167,7 +171,11 @@ class MessageItem extends React.Component 'no-quoted-text': not QuotedHTMLParser.hasQuotedHTML(@props.message.body) 'show-quoted-text': @state.showQuotedText - _renderHeaderExpansionControl: => + # This is used by subclasses of MessageItem. + # See {PendingMessageItem} + _renderHeaderSideItems: -> [] + + _renderHeaderDetailToggle: => if @state.detailedHeaders
0 - @_focusDraft(@_getMessageElement(newDraftIds[0])) + @_focusDraft(@_getMessageContainer(newDraftIds[0])) _newDraftIds: (prevState) => oldDraftIds = _.map(_.filter((prevState.messages ? []), (m) -> m.draft), (m) -> m.id) newDraftIds = _.map(_.filter((@state.messages ? []), (m) -> m.draft), (m) -> m.id) return _.difference(newDraftIds, oldDraftIds) ? [] - _getMessageElement: (id) => - @refs["message-#{id}"] + _getMessageContainer: (id) => + @refs["message-container-#{id}"] _focusDraft: (draftElement) => draftElement.focus() @@ -184,7 +185,7 @@ class MessageList extends React.Component updated[key].push(contact) unless _.findWhere(updated[key], {email: contact.email}) session.changes.add(updated) - @_focusDraft(@_getMessageElement(last.id)) + @_focusDraft(@_getMessageContainer(last.id)) else if type is 'reply' @@ -227,7 +228,7 @@ class MessageList extends React.Component matching={role:"MessageListHeaders"} exposedProps={thread: @state.currentThread}/>
- {@_messageComponents()} + {@_messageElements()}
@@ -271,44 +272,39 @@ class MessageList extends React.Component return unless @state.currentThread @_createReplyOrUpdateExistingDraft(@_replyType()) - _messageComponents: => - components = [] + _messageElements: => + elements = [] messages = @_messagesWithMinification(@state.messages) messages.forEach (message, idx) => if message.type is "minifiedBundle" - components.push(@_renderMinifiedBundle(message)) + elements.push(@_renderMinifiedBundle(message)) return collapsed = !@state.messagesExpandedState[message.id] + isLastMsg = (messages.length - 1 is idx) + isBeforeReplyArea = isLastMsg and @_hasReplyArea() - className = classNames - "message-item-wrap": true - "before-reply-area": (messages.length - 1 is idx) and @_hasReplyArea() - "unread": message.unread - "draft": message.draft - "collapsed": collapsed + messageId = @state.messageLocalIds[message.id] + messageId ?= message.id - if message.draft - components.push - else - components.push + elements.push( + + ) if @_hasReplyArea() components.push @_renderReplyArea() - return components + return elements _renderMinifiedBundle: (bundle) -> BUNDLE_HEIGHT = 36 @@ -375,7 +371,7 @@ class MessageList extends React.Component # smoothly to the top of a particular message. _onChildScrollRequest: ({messageId, rect}={}) => if messageId - @refs.messageWrap.scrollTo(@_getMessageElement(messageId), { + @refs.messageWrap.scrollTo(@_getMessageContainer(messageId), { position: ScrollRegion.ScrollPosition.Visible }) else if rect diff --git a/internal_packages/message-list/lib/pending-message-item.cjsx b/internal_packages/message-list/lib/pending-message-item.cjsx new file mode 100644 index 000000000..085d85514 --- /dev/null +++ b/internal_packages/message-list/lib/pending-message-item.cjsx @@ -0,0 +1,25 @@ +React = require 'react' +{RetinaImg} = require 'nylas-component-kit' +MessageItem = require './message-item' + +class PendingMessageItem extends MessageItem + @displayName = 'PendingMessageItem' + + _renderMessageControls: -> null + + _renderHeaderDetailToggle: -> null + + _renderHeaderSideItems: -> + styles = + width: 24 + float: "left" + marginTop: -2 + marginRight: 10 + +
+ +
+ +module.exports = PendingMessageItem diff --git a/internal_packages/message-list/spec/message-item-container-spec.cjsx b/internal_packages/message-list/spec/message-item-container-spec.cjsx new file mode 100644 index 000000000..0a4a57231 --- /dev/null +++ b/internal_packages/message-list/spec/message-item-container-spec.cjsx @@ -0,0 +1,58 @@ +React = require "react/addons" +proxyquire = require("proxyquire").noPreserveCache() +ReactTestUtils = React.addons.TestUtils + +{Thread, + Message, + DraftStore} = require 'nylas-exports' + +class MessageItem extends React.Component + @displayName: "StubMessageItem" + render: -> + +class PendingMessageItem extends MessageItem + +MessageItemContainer = proxyquire '../lib/message-item-container', + "./message-item": MessageItem + "./pending-message-item": PendingMessageItem + +{InjectedComponent} = require 'nylas-component-kit' + +testThread = new Thread(id: "t1") +testMessageId = "m1" +testMessage = new Message(id: "m1", draft: false, unread: true) +testDraft = new Message(id: "d1", draft: true, unread: true) + +describe 'MessageItemContainer', -> + + beforeEach -> + @isSendingDraft = false + spyOn(DraftStore, "isSendingDraft").andCallFake => @isSendingDraft + + renderContainer = (message) -> + ReactTestUtils.renderIntoDocument( + + ) + + it "shows composer if it's a draft", -> + @isSendingDraft = false + doc = renderContainer(testDraft) + items = ReactTestUtils.scryRenderedComponentsWithType(doc, + InjectedComponent) + expect(items.length).toBe 1 + + it "shows a pending message if it's a sending draft", -> + @isSendingDraft = true + doc = renderContainer(testDraft) + items = ReactTestUtils.scryRenderedComponentsWithType(doc, + PendingMessageItem) + expect(items.length).toBe 1 + + it "renders a message if it's not a draft", -> + @isSendingDraft = false + doc = renderContainer(testMessage) + items = ReactTestUtils.scryRenderedComponentsWithType(doc, + MessageItem) + expect(items.length).toBe 1 diff --git a/internal_packages/message-list/spec/message-list-spec.cjsx b/internal_packages/message-list/spec/message-list-spec.cjsx index 8c545a2f3..2622b7fb0 100644 --- a/internal_packages/message-list/spec/message-list-spec.cjsx +++ b/internal_packages/message-list/spec/message-list-spec.cjsx @@ -1,6 +1,6 @@ _ = require "underscore" moment = require "moment" -proxyquire = require "proxyquire" +proxyquire = require("proxyquire").noPreserveCache() CSON = require "season" React = require "react/addons" @@ -19,15 +19,19 @@ TestUtils = React.addons.TestUtils {InjectedComponent} = require 'nylas-component-kit' +MessageParticipants = require "../lib/message-participants" + MessageItem = proxyquire("../lib/message-item", { "./email-frame": React.createClass({render: ->
}) }) -MessageList = proxyquire("../lib/message-list", { +MessageItemContainer = proxyquire("../lib/message-item-container", { "./message-item": MessageItem + "./pending-message-item": MessageItem }) -MessageParticipants = require "../lib/message-participants" +MessageList = proxyquire '../lib/message-list', + "./message-item-container": MessageItemContainer me = new Namespace name: "User One", @@ -180,7 +184,7 @@ describe "MessageList", -> it "by default has zero children", -> items = TestUtils.scryRenderedComponentsWithType(@messageList, - MessageItem) + MessageItemContainer) expect(items.length).toBe 0 @@ -195,11 +199,11 @@ describe "MessageList", -> it "renders all the correct number of messages", -> items = TestUtils.scryRenderedComponentsWithType(@messageList, - MessageItem) + MessageItemContainer) expect(items.length).toBe 5 it "renders the correct number of expanded messages", -> - msgs = TestUtils.scryRenderedDOMComponentsWithClass(@messageList, "message-item-wrap collapsed") + msgs = TestUtils.scryRenderedDOMComponentsWithClass(@messageList, "collapsed message-item-wrap") expect(msgs.length).toBe 4 it "displays lists of participants on the page", -> @@ -220,7 +224,15 @@ describe "MessageList", -> messages: msgs.concat(draftMessages) expect(@messageList._focusDraft).toHaveBeenCalled() - expect(@messageList._focusDraft.mostRecentCall.args[0].props.exposedProps.localId).toEqual(draftMessages[0].id) + expect(@messageList._focusDraft.mostRecentCall.args[0].props.messageId).toEqual(draftMessages[0].id) + + it "includes drafts as message item containers", -> + msgs = @messageList.state.messages + @messageList.setState + messages: msgs.concat(draftMessages) + items = TestUtils.scryRenderedComponentsWithType(@messageList, + MessageItemContainer) + expect(items.length).toBe 6 describe "MessageList with draft", -> beforeEach -> diff --git a/src/flux/stores/draft-store.coffee b/src/flux/stores/draft-store.coffee index b00e8988a..5330d8588 100644 --- a/src/flux/stores/draft-store.coffee +++ b/src/flux/stores/draft-store.coffee @@ -397,6 +397,7 @@ class DraftStore # The user request to send the draft _onSendDraft: (draftLocalId) => @_pendingEnqueue[draftLocalId] = true + @trigger(draftLocalId) @sessionForLocalId(draftLocalId).then (session) => @_runExtensionsBeforeSend(session) @@ -415,7 +416,7 @@ class DraftStore # the line, we'll make a new session and handle them later @_doneWithSession(session) @_pendingEnqueue[draftLocalId] = false - @trigger() + @trigger(draftLocalId) Actions.queueTask(task) @_doneWithSession(session) diff --git a/src/flux/tasks/send-draft.coffee b/src/flux/tasks/send-draft.coffee index 225ddae12..9b942b751 100644 --- a/src/flux/tasks/send-draft.coffee +++ b/src/flux/tasks/send-draft.coffee @@ -39,6 +39,7 @@ class SendDraftTask extends Task # recent draft version DatabaseStore.findByLocalId(Message, @draftLocalId).then (draft) => # The draft may have been deleted by another task. Nothing we can do. + NylasAPI.incrementOptimisticChangeCount(Message, draft.id) @draft = draft if not draft return Promise.reject(new Error("We couldn't find the saved draft.")) @@ -72,6 +73,7 @@ class SendDraftTask extends Task return Promise.resolve(Task.Status.Finished) .catch APIError, (err) => + NylasAPI.decrementOptimisticChangeCount(Message, @draft.id) if err.message?.indexOf('Invalid message public id') is 0 body.reply_to_message_id = null return @_send(body) diff --git a/static/images/composer/sending-spinner.gif b/static/images/composer/sending-spinner.gif new file mode 100644 index 0000000000000000000000000000000000000000..1c72ebb554be018511ae972c3f2361dff02dce02 GIT binary patch literal 2545 zcma*pX;2es8VB%~zPr=ibVMCx-JQ^BhLDAsK)^**h(ZDp9YGuzZ%~j!}+w%FI;|aC7){7CdVvG)P{bng1y9Te*f}~*`1kQl$jwb z$tlW~rRS!X?#xfm_&6tTdp_`cjgYwbRFLNdoJCN$S-yhg`ZnC-yvedRSmOh%;Y`Gl6bY$Z-}#C=#F4%9!I1b zWQ~f+9P?;vhCxWwlwl=lrWG|7IYo;{jjmzJ5R9?f>n%-d@>kLINUc z4wM5dAO;kq<$}Dk{2-u0$I6@2N}&cUx9nmV1dYc8jfC}%=F9WCg^OQK9C6poh#2!A z3^EU*UFZvS^)?bu3T?J;@Ahb~%I?+@4!l5!*TjC}GIslNan-RCrrd~PdHYnNLJk+m&`$Y+NV(e>CCu%R#_8GqY4cv#j`#uRWdsg9DxWy(?oOvgCU}&@jy%c!H&-Q zqXJxajAtmQRoRa9V-RFXXh-bK*;Fum{BjpkYQGX~i@OZ^Dx0n&H}kvGKqQ?w(6iGXu_g08T|_hp#ZvFzIwKF*a=oMJ~3UGAjZ?g}GOxm44td zXoyYrU*I=y*vHv89hkYH(v5R#wc)BC3dZJKb3K)f>zaM3%JP(mpecViP0eKKYf3zy z->jx_mc?mCtPEvCQ?uppk?eLJt}_IR7giW%Jr)RyI!+E-voIs*lXI*z`GQc_&D#X( z{6G};HPYj6O|$lXxBJeDaweqa{4L=tOZCjTI^&UOxXg})LRG_cr^B9Rqt(i5ORbQX zq`_xCRsH>xEYY%&*Nyi#{S_JZNlTm#K56`RI%7^amom;*h90Si&g1CfaFV3D|a!`3Y-GKKbL*KSbl z>I96`TR@CqPJl(>QqB~RvK~-U)`e`l4LIqj+IU^~yyIe*|BRVB>4Bup%j{tLdKz4j zY^<8P8m~GRGz*yv0&-RJE+-keJ+%m3wNeopzsltWd->eWmBVwUr)pX` zK~CD<;~Z*Uy3W`3+MrEYxm5qYQ!z%YI;y7DTG`UVH0;@{M{!B&id_}3DBQ?zsotuR zEGLdRx25nLm%-wjlnEi;-aN_1S7???rO~WgA67jjr&(vRa3y$u#kqJbeKnw z{!T!1li9>M+sJ6AUe+*9d}2uGjhzd z|L1Rtp8uTGYyZoQ*`DS^m2dw-X{a)l+3m?ncvn^+O>)hdd3(hMtlhkRGns{<8c0I! zDDjpmwtj?@!6kA|iu3q+Ai;@JR+ zfk+ln&YFC{4bhK6IxVgLs4W%^8Lk`qzWU*L>yq0A3;l}{!wKZ!ue)C)SKI)9dl1hl zhIRLV@8E}rwvE{gX(}$f6x*k)_`*Ijt1=EU-Ls6-(phomeQBgtUs z5Xz~Cd*nE)Ac!0i4ep}Z1AugMB(&F?)#CU{Qc{Sp^vKsdL}vRB30H+Bbzrn`M##H3 z{W8dc_mDroEE+p8_}mnJtzZ4!RNe)zhB)Ds;S57nYSJxtek>^~&(7B+N5MPf2+2xx z5Dl&4X|c@f{Kd|z1r+N|$DmsoVp*3yOdxT^J^-VAk)Z@$4^XrPrFP-Co+MXZ+KJ(W z{JNYvraLLWA;&tRhIKOvhW|HC|L-dLvAUF(MG0(Nl?4tB{RzN7I(}Cb%hwN{crFC8 zji#aJElKvDFV+&VI1V?oUMA>*kto0^;3W8FQBSZ|{ z$v~TqE=(8DZa^i$^oht&h};P1N&wMXorKh*Z68gPV&ouy>%f36Oqkwemyeas$Qbz# zV?7Jy%o7KY6^I=P@eCji%W`o5sf(5hySYo9$l4e2`(hIV_?=H-#R6}0$WVA|*(K@3 z=5?@RlcLh(meW%A4)hGzcvEpm(_w?>zhL*i&s9$2>r zAtk{8Cia|+Y+V!uX9BtpXoF%lswuRKsM!pSs!?yhlCy!269K0|b M?FSZn2B>%I-}ej|s{jB1 literal 0 HcmV?d00001