diff --git a/.gitignore b/.gitignore index a28d8057d30..73950188675 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ /aws/static /aws/templates/**/*.html /aws/templates/auth +/aws/templates/sw.js /aws/version /bower_components /deploy diff --git a/app/font/Wire.ttf b/app/font/Wire.ttf old mode 100755 new mode 100644 index 1184bccda1f..fc4995be2cf Binary files a/app/font/Wire.ttf and b/app/font/Wire.ttf differ diff --git a/app/page/template/_dist/app.htm b/app/page/template/_dist/app.htm index 35ff476f0b6..08c9ff63b20 100644 --- a/app/page/template/_dist/app.htm +++ b/app/page/template/_dist/app.htm @@ -136,6 +136,7 @@ + @@ -193,6 +194,8 @@ + + @@ -232,10 +235,12 @@ + + @@ -244,6 +249,8 @@ + + diff --git a/app/page/template/content/collection-details.htm b/app/page/template/content/collection-details.htm new file mode 100644 index 00000000000..81a3d072f8a --- /dev/null +++ b/app/page/template/content/collection-details.htm @@ -0,0 +1,59 @@ +
+ + + +
+ +
+ +
+ + + Pictures in + + +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + +
diff --git a/app/page/template/content/collection.htm b/app/page/template/content/collection.htm new file mode 100644 index 00000000000..76602cb2152 --- /dev/null +++ b/app/page/template/content/collection.htm @@ -0,0 +1,121 @@ +
+ + + +
+ + + +
+ +
+ +
+ +
+ + + + +
+
+ + Pictures + + + +
+
+ + + +
+
+ + + +
+
+ + Links + + + +
+
+ + + +
+
+ + + +
+
+ + Videos + + + +
+
+ + + +
+
+ + + +
+
+ + Audio messages + + + +
+
+ + + +
+
+ + + +
+
+ + files + + + +
+
+ + + +
+
+ + + + + + +
+
+
No items
+
+ + + +
+ + + +
diff --git a/app/page/template/content/conversation.htm b/app/page/template/content/conversation.htm index f6d4e9169e6..f7600005618 100644 --- a/app/page/template/content/conversation.htm +++ b/app/page/template/content/conversation.htm @@ -13,7 +13,6 @@ #include('content/conversation/message-list.htm') #include('content/conversation/conversation-input.htm') #include('content/conversation/participants.htm') - #include('content/conversation/detail-view.htm') #include('content/conversation/giphy.htm') diff --git a/app/page/template/content/conversation/conversation-titlebar.htm b/app/page/template/content/conversation/conversation-titlebar.htm index 8c8e9011aa6..d782ca3377d 100644 --- a/app/page/template/content/conversation/conversation-titlebar.htm +++ b/app/page/template/content/conversation/conversation-titlebar.htm @@ -1,6 +1,12 @@
+
+ +
+ @@ -35,7 +41,7 @@
- diff --git a/app/page/template/content/conversation/detail-view.htm b/app/page/template/content/conversation/detail-view.htm deleted file mode 100644 index ddf8d0ef580..00000000000 --- a/app/page/template/content/conversation/detail-view.htm +++ /dev/null @@ -1,6 +0,0 @@ - diff --git a/app/page/template/detail-view.htm b/app/page/template/detail-view.htm new file mode 100644 index 00000000000..5b7b5c03d67 --- /dev/null +++ b/app/page/template/detail-view.htm @@ -0,0 +1,6 @@ + diff --git a/app/page/template/partials/template-message.htm b/app/page/template/partials/template-message.htm index 45b20ebab4e..a3a5a5ad353 100644 --- a/app/page/template/partials/template-message.htm +++ b/app/page/template/partials/template-message.htm @@ -92,17 +92,17 @@
- + - + - + - + diff --git a/app/page/template/wire-main.htm b/app/page/template/wire-main.htm index f251149e553..56e362947d7 100644 --- a/app/page/template/wire-main.htm +++ b/app/page/template/wire-main.htm @@ -24,8 +24,14 @@ #include('content/conversation.htm') + + #include('content/collection.htm') + + + #include('content/collection-details.htm') + - #include('content/preferences-about.htm') + #include('content/preferences-about.htm') #include('content/preferences-account.htm') @@ -47,6 +53,7 @@
+ #include('detail-view.htm') #include('video-calling.htm') #include('warning.htm') #include('modals.htm') diff --git a/app/script/components/asset/assetHeader.coffee b/app/script/components/asset/assetHeader.coffee new file mode 100644 index 00000000000..06725cc6f47 --- /dev/null +++ b/app/script/components/asset/assetHeader.coffee @@ -0,0 +1,33 @@ +# +# Wire +# Copyright (C) 2016 Wire Swiss GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see http://www.gnu.org/licenses/. +# + +window.z ?= {} +z.components ?= {} + +class z.components.AssetHeader + + constructor: (params) -> + @message_et = params.message + + +ko.components.register 'asset-header', + viewModel: z.components.AssetHeader + template: """ + + + """ diff --git a/app/script/components/asset/audioAsset.coffee b/app/script/components/asset/audioAsset.coffee index 5cbb2f1c4aa..d5c6d12fed5 100644 --- a/app/script/components/asset/audioAsset.coffee +++ b/app/script/components/asset/audioAsset.coffee @@ -28,8 +28,11 @@ class z.components.AudioAssetComponent ### constructor: (params, component_info) -> @logger = new z.util.Logger 'AudioAssetComponent', z.config.LOGGER.OPTIONS - @asset = params.asset - @expired = params.expired + + @message = ko.unwrap params.message + @asset = @message.get_first_asset() + @expired = @message.is_expired + @header = params.header or false @audio_src = ko.observable() @audio_element = $(component_info.element).find('audio')[0] @@ -53,16 +56,13 @@ class z.components.AudioAssetComponent @audio_time @audio_element.currentTime on_play_button_clicked: => - if @audio_src()? - @audio_element?.play() - else - @asset.load() - .then (blob) => - @audio_src window.URL.createObjectURL blob - @audio_is_loaded true - @audio_element?.play() - .catch (error) => - @logger.error 'Failed to load audio asset ', error + Promise.resolve().then => + if not @audio_src()? + @asset.load().then (blob) => @audio_src window.URL.createObjectURL blob + .then => + @audio_element.play() + .catch (error) => + @logger.error 'Failed to load audio asset ', error on_pause_button_clicked: => @audio_element?.pause() @@ -75,45 +75,49 @@ class z.components.AudioAssetComponent duration_actual: duration type: z.util.get_file_extension @asset.file_name + dispose: => + window.URL.revokeObjectURL @audio_src() + ko.components.register 'audio-asset', viewModel: createViewModel: (params, component_info) -> return new z.components.AudioAssetComponent params, component_info template: """ + - + + +
- - - +
- - - - - - - +
+ + + + + + + + + + + - - - - +
""" diff --git a/app/script/components/asset/fileAsset.coffee b/app/script/components/asset/fileAsset.coffee index a29d675987d..ae11389e56a 100644 --- a/app/script/components/asset/fileAsset.coffee +++ b/app/script/components/asset/fileAsset.coffee @@ -26,9 +26,12 @@ class z.components.FileAssetComponent @param params [Object] @option params [ko.observableArray] asset ### - constructor: (params, component_info) -> - @asset = params.asset - @expired = params.expired + constructor: (params) -> + + @message = ko.unwrap params.message + @asset = @message.get_first_asset() + @expired = @message.is_expired + @header = params.header or false @circle_upload_progress = ko.pureComputed => size = if @large then '200' else '100' @@ -47,10 +50,10 @@ ko.components.register 'file-asset', viewModel: createViewModel: (params, component_info) -> return new z.components.FileAssetComponent params, component_info template: """ - -
- + + +
- - - +
diff --git a/app/script/components/asset/linkPreviewAsset.coffee b/app/script/components/asset/linkPreviewAsset.coffee index 36091b7becd..ce56e8983f5 100644 --- a/app/script/components/asset/linkPreviewAsset.coffee +++ b/app/script/components/asset/linkPreviewAsset.coffee @@ -27,11 +27,11 @@ class z.components.LinkPreviewAssetComponent @option params [z.entity.LinkPreview] preview ### constructor: (params, component_info) -> - @preview = params.preview + @preview = ko.unwrap params.preview @viewport_changed = params.viewport_changed @element = component_info.element @url = @preview.original_url - @expired = params.expired + @expired = params.expired or ko.observable false on_link_preview_click: => z.util.safe_window_open @url @@ -48,10 +48,10 @@ ko.components.register 'link-preview-asset', + diff --git a/app/script/components/asset/linkPreviewCompactAsset.coffee b/app/script/components/asset/linkPreviewCompactAsset.coffee new file mode 100644 index 00000000000..f22ce4f2c86 --- /dev/null +++ b/app/script/components/asset/linkPreviewCompactAsset.coffee @@ -0,0 +1,80 @@ +# +# Wire +# Copyright (C) 2016 Wire Swiss GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see http://www.gnu.org/licenses/. +# + +window.z ?= {} +z.components ?= {} + +class z.components.LinkPreviewCompactAssetComponent + ### + Construct a new audio asset. + + @param params [Object] + @option params [z.entity.LinkPreview] preview + ### + constructor: (params, component_info) -> + + @message_et = ko.unwrap params.message + @header = params.header or false + + @preview = @message_et.get_first_asset().previews()[0] + @element = component_info.element + @url = @preview.original_url + + @element.addEventListener 'click', @on_link_preview_click + + on_link_preview_click: => + z.util.safe_window_open @url + + dispose: => + @element.removeEventListener 'click', @on_link_preview_click + + +ko.components.register 'link-preview-compact-asset', + viewModel: createViewModel: (params, component_info) -> + return new z.components.LinkPreviewCompactAssetComponent params, component_info + template: """ + + + + + + + + + + """ diff --git a/app/script/components/asset/videoAsset.coffee b/app/script/components/asset/videoAsset.coffee index 6d7297f64da..d1d95bc7753 100644 --- a/app/script/components/asset/videoAsset.coffee +++ b/app/script/components/asset/videoAsset.coffee @@ -28,8 +28,11 @@ class z.components.VideoAssetComponent ### constructor: (params, component_info) -> @logger = new z.util.Logger 'VideoAssetComponent', z.config.LOGGER.OPTIONS - @asset = params.asset - @expired = params.expired + + @message = ko.unwrap params.message + @asset = @message.get_first_asset() + @expired = @message.is_expired + @preview_subscription = undefined @video_element = $(component_info.element).find('video')[0] @@ -91,14 +94,12 @@ class z.components.VideoAssetComponent dispose: => @preview_subscription?.dispose() + window.URL.revokeObjectURL @video_src() ko.components.register 'video-asset', viewModel: createViewModel: (params, component_info) -> return new z.components.VideoAssetComponent params, component_info template: """ - -
-
diff --git a/app/script/components/image.coffee b/app/script/components/image.coffee index 3e059524473..339a281679f 100644 --- a/app/script/components/image.coffee +++ b/app/script/components/image.coffee @@ -23,16 +23,20 @@ class z.components.Image constructor: (params) -> @asset = ko.unwrap params.asset @asset_src = ko.observable() + @asset_is_loading = ko.observable false @on_entered_viewport = => @load_image_asset() return true @load_image_asset = => - @asset.load().then (blob) => @asset_src window.URL.createObjectURL blob + @asset_is_loading true + @asset.load().then (blob) => + @asset_is_loading false + @asset_src window.URL.createObjectURL blob dispose: => - window.URL.revokeObjectURL @asset_src + window.URL.revokeObjectURL @asset_src() ko.components.register 'image-component', @@ -44,7 +48,7 @@ ko.components.register 'image-component', -
+
diff --git a/app/script/conversation/ConversationRepository.coffee b/app/script/conversation/ConversationRepository.coffee index 8043596a686..1f2def404b9 100644 --- a/app/script/conversation/ConversationRepository.coffee +++ b/app/script/conversation/ConversationRepository.coffee @@ -218,6 +218,18 @@ class z.conversation.ConversationRepository @logger.info "Could not load events for conversation: #{conversation_et.id}", error throw error + ### + Get messages for given category. Category param acts as lower bound + @param conversation_id [String] + @param catogory [z.message.MessageCategory.NONE] + @return [Promise] Array of z.entity.Message entities + ### + get_events_for_category: (conversation_et, catogory = z.message.MessageCategory.NONE) => + @conversation_service.load_events_with_category_from_db conversation_et.id, catogory + .then (events) => + message_ets = @event_mapper.map_json_events events, conversation_et + return Promise.all (@_update_user_ets message_et for message_et in message_ets) + ### Get conversation unread events. @param conversation_et [z.entity.Conversation] Conversation to start from @@ -528,18 +540,15 @@ class z.conversation.ConversationRepository ### Add users to an existing conversation. - @param conversation_et [z.entity.Conversation] Conversation to add users to @param user_ids [Array] IDs of users to be added to the conversation - @param callback [Function] Function to be called on server return ### - add_members: (conversation_et, users_ids, callback) => + add_members: (conversation_et, users_ids) => @conversation_service.post_members conversation_et.id, users_ids .then (response) -> amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.SessionEventName.INTEGER.USERS_ADDED_TO_CONVERSATIONS, users_ids.length amplify.publish z.event.WebApp.EVENT.INJECT, response - callback?() .catch (error_response) -> if error_response.label is z.service.BackendClientError::LABEL.TOO_MANY_MEMBERS amplify.publish z.event.WebApp.WARNING.MODAL, z.ViewModel.ModalType.TOO_MANY_MEMBERS, @@ -658,18 +667,14 @@ class z.conversation.ConversationRepository ### Rename conversation. - @param conversation_et [z.entity.Conversation] Conversation to rename @param name [String] New conversation name - @param callback [Function] Function to be called on server return ### - rename_conversation: (conversation_et, name, callback) => + rename_conversation: (conversation_et, name) => @conversation_service.update_conversation_properties conversation_et.id, name .then (response) -> amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.SessionEventName.INTEGER.CONVERSATION_RENAMED amplify.publish z.event.WebApp.EVENT.INJECT, response - .then -> - callback?() reset_session: (user_id, client_id, conversation_id) => @logger.info "Resetting session with client '#{client_id}' of user '#{user_id}'" @@ -707,9 +712,8 @@ class z.conversation.ConversationRepository @param conversation_et [z.entity.Conversation] Conversation to send message in @param url [String] URL of giphy image @param tag [String] tag tag used for gif search - @param callback [Function] Function to be called on server return ### - send_gif: (conversation_et, url, tag, callback) => + send_gif: (conversation_et, url, tag) => if not tag tag = z.localization.Localizer.get_text z.string.extensions_giphy_random @@ -723,8 +727,6 @@ class z.conversation.ConversationRepository .then (blob) => @send_message message, conversation_et @upload_images conversation_et, [blob] - .then -> - callback?() ### Toggle a conversation between silence and notify. @@ -756,9 +758,8 @@ class z.conversation.ConversationRepository ### Un-archive a conversation. @param conversation_et [z.entity.Conversation] Conversation to rename - @param callback [Function] Function to be called on return ### - unarchive_conversation: (conversation_et, callback) => + unarchive_conversation: (conversation_et) => return Promise.reject new z.conversation.ConversationError z.conversation.ConversationError::TYPE.CONVERSATION_NOT_FOUND if not conversation_et payload = @@ -770,12 +771,10 @@ class z.conversation.ConversationRepository response = {data: payload} @_on_member_update conversation_et, response @logger.info "Unarchived conversation '#{conversation_et.id}' on '#{payload.otr_archived_ref}'" - callback?() return response .catch (error) => reject_error = new Error "Conversation '#{conversation_et.id}' could not be unarchived: #{error.code}" @logger.warn reject_error.message, error - callback?() throw reject_error ### @@ -1697,7 +1696,8 @@ class z.conversation.ConversationRepository @param event_json [Object] JSON data of 'conversation.message-add' or 'conversation.knock' event ### _on_add_event: (conversation_et, event_json) -> - @_add_event_to_conversation event_json, conversation_et, (message_et) => + @_add_event_to_conversation event_json, conversation_et + .then (message_et) => @send_confirmation_status conversation_et, message_et @_send_event_notification conversation_et, message_et @@ -1798,7 +1798,8 @@ class z.conversation.ConversationRepository conversation_et.removed_from_conversation false @update_participating_user_ets conversation_et, => - @_add_event_to_conversation event_json, conversation_et, (message_et) -> + @_add_event_to_conversation event_json, conversation_et + .then (message_et) -> amplify.publish z.event.WebApp.SYSTEM_NOTIFICATION.NOTIFY, conversation_et, message_et ### @@ -1808,7 +1809,8 @@ class z.conversation.ConversationRepository @param event_json [Object] JSON data of 'conversation.member-leave' event ### _on_member_leave: (conversation_et, event_json) -> - @_add_event_to_conversation event_json, conversation_et, (message_et) => + @_add_event_to_conversation event_json, conversation_et + .then (message_et) => for user_et in message_et.user_ets() if conversation_et.call() if user_et.is_me @@ -1937,7 +1939,8 @@ class z.conversation.ConversationRepository @param event_json [Object] JSON data of 'conversation.rename' event ### _on_rename: (conversation_et, event_json) -> - @_add_event_to_conversation event_json, conversation_et, (message_et) => + @_add_event_to_conversation event_json, conversation_et + .then (message_et) => @conversation_mapper.update_properties conversation_et, event_json.data amplify.publish z.event.WebApp.SYSTEM_NOTIFICATION.NOTIFY, conversation_et, message_et @@ -1951,10 +1954,10 @@ class z.conversation.ConversationRepository @param json [Object] Event data @param conversation_et [z.entity.Conversation] Conversation entity the event will be added to - @param callback [Function] Function to be called on return + @return [Promise] Promise that resolves with the message entity for the event ### - _add_event_to_conversation: (json, conversation_et, callback) -> - message_et = @event_mapper.map_json_event json, conversation_et + _add_event_to_conversation: (json, conversation_et) -> + message_et = @event_mapper.map_json_event json, conversation_et, true @_update_user_ets message_et .then (message_et) => if conversation_et @@ -1965,7 +1968,7 @@ class z.conversation.ConversationRepository custom_data = message_type: message_et.type Raygun.send error, custom_data - callback? message_et + return message_et ### Convert multiple JSON events into entities and add them to a given conversation. @@ -1977,7 +1980,7 @@ class z.conversation.ConversationRepository ### _add_events_to_conversation: (events, conversation_et, prepend = true) -> return Promise.resolve().then => - message_ets = @event_mapper.map_json_events events, conversation_et + message_ets = @event_mapper.map_json_events events, conversation_et, true return Promise.all (@_update_user_ets message_et for message_et in message_ets) .then (message_ets) -> if prepend and conversation_et.messages().length > 0 @@ -2343,7 +2346,8 @@ class z.conversation.ConversationRepository _update_link_preview: (conversation_et, event_json) => @get_message_in_conversation_by_id conversation_et, event_json.id .then (original_message_et) => - @_delete_message conversation_et, original_message_et + if original_message_et.get_first_asset().previews().length is 0 + @_delete_message conversation_et, original_message_et .then -> return event_json diff --git a/app/script/conversation/ConversationService.coffee b/app/script/conversation/ConversationService.coffee index e908e7f6cc2..b4fcfda292e 100644 --- a/app/script/conversation/ConversationService.coffee +++ b/app/script/conversation/ConversationService.coffee @@ -274,13 +274,11 @@ class z.conversation.ConversationService @return [Promise] Promise that resolves with the retrieved records @see https://github.com/dfahlander/Dexie.js/issues/366 ### - load_events_from_db: (conversation_id, lower_bound = new Date(0), upper_bound = new Date(), limit = z.config.MESSAGES_FETCH_LIMIT) -> + load_events_from_db: (conversation_id, lower_bound = new Date(0), upper_bound = new Date(), limit = Number.MAX_SAFE_INTEGER) -> if not _.isDate(lower_bound) or not _.isDate upper_bound throw new Error "Lower bound (#{typeof lower_bound}) and upper bound (#{typeof upper_bound}) must be of type 'Date'." else if lower_bound.getTime() > upper_bound.getTime() throw new Error "Lower bound (#{lower_bound.getTime()}) cannot be greater than upper bound (#{upper_bound.getTime()})." - else if z.util.Environment.browser.edge - return @_load_events_from_db_deprecated conversation_id, lower_bound.getTime(), upper_bound.getTime(), limit @storage_service.db[@storage_service.OBJECT_STORE_CONVERSATION_EVENTS] .where '[conversation+time]' @@ -292,6 +290,18 @@ class z.conversation.ConversationService @logger.error "Failed to load events for conversation '#{conversation_id}' from database: '#{error.message}'" throw error + ### + Get events with given category. + @param conversation_id [String] ID of conversation to add users to + @param category [z.message.MessageCategory] will be used as lower bound + @return [Promise] + ### + load_events_with_category_from_db: (conversation_id, category) -> + @storage_service.db[@storage_service.OBJECT_STORE_CONVERSATION_EVENTS] + .where '[conversation+category]' + .between [conversation_id, category], [conversation_id, z.message.MessageCategory.LIKED], true, true + .sortBy 'time' + ### Add a bot to an existing conversation. diff --git a/app/script/conversation/ConversationServiceNoCompound.coffee b/app/script/conversation/ConversationServiceNoCompound.coffee new file mode 100644 index 00000000000..bc4ef9120bb --- /dev/null +++ b/app/script/conversation/ConversationServiceNoCompound.coffee @@ -0,0 +1,71 @@ +# +# Wire +# Copyright (C) 2016 Wire Swiss GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see http://www.gnu.org/licenses/. +# + +window.z ?= {} +z.conversation ?= {} + +# TODO: This function can be removed once Microsoft Edge's IndexedDB supports compound indices: +# - https://developer.microsoft.com/en-us/microsoft-edge/platform/status/indexeddbarraysandmultientrysupport/ +class z.conversation.ConversationServiceNoCompound extends z.conversation.ConversationService + + constructor: (client, storage_service) -> + super client, storage_service + + ### + Load conversation events. Start and end are not included. Events are always sorted beginning with the newest timestamp. + + @param conversation_id [String] ID of conversation + @param start [Number] starting from this timestamp + @param end [Number] stop when reaching timestamp + @param limit [Number] Amount of events to load + @return [Promise] Promise that resolves with the retrieved records + ### + load_events_from_db: (conversation_id, lower_bound = new Date(0), upper_bound = new Date(), limit) -> + if not _.isDate(lower_bound) or not _.isDate upper_bound + throw new Error "Lower bound (#{typeof lower_bound}) and upper bound (#{typeof upper_bound}) must be of type 'Date'." + else if lower_bound.getTime() > upper_bound.getTime() + throw new Error "Lower bound (#{lower_bound.getTime()}) cannot be greater than upper bound (#{upper_bound.getTime()})." + + lower_bound = lower_bound.getTime() + upper_bound = upper_bound.getTime() + + @storage_service.db[@storage_service.OBJECT_STORE_CONVERSATION_EVENTS] + .where 'conversation' + .equals conversation_id + .reverse() + .sortBy 'time' + .then (records) -> + return records.filter (record) -> + timestamp = new Date(record.time).getTime() + return timestamp >= lower_bound and timestamp < upper_bound + .then (records) -> + return records.slice 0, limit + + ### + Get events with given category. + @param conversation_id [String] ID of conversation to add users to + @param category [z.message.MessageCategory] will be used as lower bound + @return [Promise] + ### + load_events_with_category_from_db: (conversation_id, category) -> + @storage_service.db[@storage_service.OBJECT_STORE_CONVERSATION_EVENTS] + .where 'conversation' + .equals conversation_id + .sortBy 'time' + .then (records) -> + records.filter (record) -> record.category >= category diff --git a/app/script/conversation/EventMapper.coffee b/app/script/conversation/EventMapper.coffee index 50d849355f7..ab2095e7610 100644 --- a/app/script/conversation/EventMapper.coffee +++ b/app/script/conversation/EventMapper.coffee @@ -37,13 +37,13 @@ class z.conversation.EventMapper @return [Array] Mapped message entities ### - map_json_events: (events, conversation_et) -> - events = (@map_json_event event, conversation_et for event in events.reverse() when event isnt undefined) + map_json_events: (events, conversation_et, should_create_dummy_image) -> + events = (@map_json_event event, conversation_et, should_create_dummy_image for event in events.reverse() when event isnt undefined) return events.filter (x) -> x isnt undefined - map_json_event: (event, conversation_et) => + map_json_event: (event, conversation_et, should_create_dummy_image) => try - return @_map_json_event event, conversation_et + return @_map_json_event event, conversation_et, should_create_dummy_image catch error @logger.error "Failed to map event: #{error.message}", {error: error, event: event} return undefined @@ -56,10 +56,10 @@ class z.conversation.EventMapper @return [z.entity.Message] Mapped message entity ### - _map_json_event: (event, conversation_et) -> + _map_json_event: (event, conversation_et, should_create_dummy_image) -> switch event.type when z.event.Backend.CONVERSATION.ASSET_ADD - message_et = @_map_event_asset_add event + message_et = @_map_event_asset_add event, should_create_dummy_image when z.event.Backend.CONVERSATION.KNOCK message_et = @_map_event_ping event when z.event.Backend.CONVERSATION.MESSAGE_ADD @@ -93,6 +93,7 @@ class z.conversation.EventMapper message_et.primary_key = z.storage.StorageService.construct_primary_key event message_et.type = event.type message_et.version = event.version or 1 + message_et.category = event.category message_et.conversation_id = conversation_et.id @@ -124,10 +125,10 @@ class z.conversation.EventMapper @return [z.entity.NormalMessage] Normal message entity ### - _map_event_asset_add: (event) -> + _map_event_asset_add: (event, should_create_dummy_image) -> message_et = new z.entity.ContentMessage() if event.data?.info.tag is z.assets.ImageSizeType.MEDIUM - message_et.assets.push @_map_asset_medium_image event + message_et.assets.push @_map_asset_medium_image event, should_create_dummy_image message_et.nonce = event.data.info.nonce return message_et @@ -395,7 +396,7 @@ class z.conversation.EventMapper @return [z.entity.MediumImage] Medium image asset entity ### - _map_asset_medium_image: (event) -> + _map_asset_medium_image: (event, should_create_dummy_image) -> asset_et = new z.entity.MediumImage event.data.id asset_et.width = event.data.info.width asset_et.height = event.data.info.height @@ -404,7 +405,8 @@ class z.conversation.EventMapper asset_et.resource z.assets.AssetRemoteData.v3 event.data.key, event.data.otr_key, event.data.sha256, event.data.token, true else asset_et.resource z.assets.AssetRemoteData.v2 event.conversation, asset_et.id, event.data.otr_key, event.data.sha256, true - asset_et.dummy_url = z.util.dummy_image asset_et.width, asset_et.height + if should_create_dummy_image + asset_et.dummy_url = z.util.dummy_image asset_et.width, asset_et.height return asset_et ### diff --git a/app/script/cryptography/CryptographyRepository.coffee b/app/script/cryptography/CryptographyRepository.coffee index 257914c488d..d138c74048a 100644 --- a/app/script/cryptography/CryptographyRepository.coffee +++ b/app/script/cryptography/CryptographyRepository.coffee @@ -529,7 +529,9 @@ class z.cryptography.CryptographyRepository @return [Promise] Promise that will resolve with the saved record ### save_unencrypted_event: (event) -> - @storage_repository.save_conversation_event event + Promise.resolve().then => + event.category = z.message.MessageCategorization.category_from_event event + @storage_repository.save_conversation_event event .catch (error) => @logger.error "Saving unencrypted message failed: #{error.message}", error throw error diff --git a/app/script/entity/message/Message.coffee b/app/script/entity/message/Message.coffee index c1fdcb0bc8e..b7a65b5edd1 100644 --- a/app/script/entity/message/Message.coffee +++ b/app/script/entity/message/Message.coffee @@ -61,6 +61,9 @@ class z.entity.Message @visible = ko.observable true @version = 1 + # z.message.MessageCategory + @category = undefined + @display_timestamp_short = => date = moment.unix @timestamp / 1000 return date.local().format 'HH:mm' @@ -207,7 +210,7 @@ class z.entity.Message Check if ephemeral message is expired. @return [Boolean] ### - is_expired: -> + is_expired: => return @ephemeral_expires() is true ### diff --git a/app/script/localization/strings.coffee b/app/script/localization/strings.coffee index 990ca2ec6e5..aef403fada7 100644 --- a/app/script/localization/strings.coffee +++ b/app/script/localization/strings.coffee @@ -270,6 +270,9 @@ z.string.conversation_edit_timestamp = 'Edited on %@timestamp' z.string.conversation_likes_caption = '%@number people' z.string.conversation_send_pasted_file = 'Pasted image at %date' +# Collection +z.string.collection_show_all = 'Show all %no' + # Archive z.string.archive_header = 'Archive' diff --git a/app/script/main/app.coffee b/app/script/main/app.coffee index 4fdaa91a98a..94c4337cb2e 100644 --- a/app/script/main/app.coffee +++ b/app/script/main/app.coffee @@ -65,10 +65,14 @@ class z.main.App service.web_socket = new z.event.WebSocketService @auth.client service.client = new z.client.ClientService @auth.client, service.storage - service.conversation = new z.conversation.ConversationService @auth.client, service.storage service.notification = new z.event.NotificationService @auth.client, service.storage service.announce = new z.announce.AnnounceService() + if z.util.Environment.browser.edge + service.conversation = new z.conversation.ConversationServiceNoCompound @auth.client, service.storage + else + service.conversation = new z.conversation.ConversationService @auth.client, service.storage + return service # Create all app repositories. @@ -115,6 +119,7 @@ class z.main.App view.content = new z.ViewModel.content.ContentViewModel 'right', @repository.audio, @repository.call_center, @repository.client, @repository.conversation, @repository.cryptography, @repository.giphy, @repository.media, @repository.search, @repository.user, @repository.properties view.list = new z.ViewModel.list.ListViewModel 'left', view.content, @repository.call_center, @repository.connect, @repository.conversation, @repository.search, @repository.user, @repository.properties view.title = new z.ViewModel.WindowTitleViewModel view.content.content_state, @repository.user, @repository.conversation + view.lightbox = new z.ViewModel.ImageDetailViewViewModel 'detail-view' view.warnings = new z.ViewModel.WarningsViewModel 'warnings' view.modals = new z.ViewModel.ModalsViewModel 'modals' diff --git a/app/script/message/MessageCategorization.coffee b/app/script/message/MessageCategorization.coffee new file mode 100644 index 00000000000..5882bd4c647 --- /dev/null +++ b/app/script/message/MessageCategorization.coffee @@ -0,0 +1,77 @@ +# +# Wire +# Copyright (C) 2016 Wire Swiss GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see http://www.gnu.org/licenses/. +# + +window.z ?= {} +z.message ?= {} + +z.message.MessageCategorization = do -> + + check_text = (event) -> + if event.type is z.event.Backend.CONVERSATION.MESSAGE_ADD + category = z.message.MessageCategory.TEXT + + if event.data.previews?.length > 0 + category = category | z.message.MessageCategory.LINK | z.message.MessageCategory.LINK_PREVIEW + + return category + + check_image = (event) -> + if event.type is z.event.Backend.CONVERSATION.ASSET_ADD + category = z.message.MessageCategory.IMAGE + + if event.data.content_type is 'image/gif' + category = category | z.message.MessageCategory.GIF + + return category + + check_file = (event) -> + if event.type is z.event.Client.CONVERSATION.ASSET_META + return z.message.MessageCategory.FILE + + check_ping = (event) -> + if event.type is z.event.Backend.CONVERSATION.KNOCK + return z.message.MessageCategory.KNOCK + + check_location = (event) -> + if event.type is z.event.Client.CONVERSATION.LOCATION + return z.message.MessageCategory.LOCATION + + category_from_event = (event) -> + try + category = z.message.MessageCategory.NONE + + if event.ephemeral_expires # String, Number, true + return z.message.MessageCategory.NONE + + for check in [check_text, check_image, check_file, check_ping, check_location] + temp_category = check(event) + if temp_category? + category = temp_category + break + + if event.reactions? and Object.keys(event.reactions).length > 0 + category = category | z.message.MessageCategory.LIKED + + return category + + catch error + return z.message.MessageCategory.UNDEFINED + + return { + category_from_event: category_from_event + } diff --git a/app/script/message/MessageCategory.coffee b/app/script/message/MessageCategory.coffee new file mode 100644 index 00000000000..8479d2f9077 --- /dev/null +++ b/app/script/message/MessageCategory.coffee @@ -0,0 +1,37 @@ +# +# Wire +# Copyright (C) 2016 Wire Swiss GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see http://www.gnu.org/licenses/. +# + +window.z ?= {} +z.message ?= {} + +z.message.MessageCategory = + NONE: 0 + UNDEFINED: 1 << 0 + EXCLUDED: 1 << 1 + KNOCK: 1 << 2 + SYSTEM: 1 << 3 + TEXT: 1 << 4 + LINK: 1 << 5 + LINK_PREVIEW: 1 << 6 + IMAGE: 1 << 7 + GIF: 1 << 8 + FILE: 1 << 9 + AUDIO: 1 << 10 + VIDEO: 1 << 11 + LOCATION: 1 << 12 + LIKED: 1 << 13 diff --git a/app/script/storage/StorageService.coffee b/app/script/storage/StorageService.coffee index e8abe4f72c6..3e84d000ad7 100644 --- a/app/script/storage/StorageService.coffee +++ b/app/script/storage/StorageService.coffee @@ -126,6 +126,15 @@ class z.storage.StorageService "#{@OBJECT_STORE_PREKEYS}": '' "#{@OBJECT_STORE_SESSIONS}": '' + version_10 = + "#{@OBJECT_STORE_AMPLIFY}": '' + "#{@OBJECT_STORE_CLIENTS}": ', meta.primary_key' + "#{@OBJECT_STORE_CONVERSATION_EVENTS}": ', category, conversation, time, type, [conversation+time], [conversation+category]' + "#{@OBJECT_STORE_CONVERSATIONS}": ', id, last_event_timestamp' + "#{@OBJECT_STORE_KEYS}": '' + "#{@OBJECT_STORE_PREKEYS}": '' + "#{@OBJECT_STORE_SESSIONS}": '' + @db = new Dexie @db_name @db.on 'blocked', => @@ -170,6 +179,11 @@ class z.storage.StorageService if event.type is z.event.Client.CONVERSATION.DELETE_EVERYWHERE event.time = new Date(event.time).toISOString() @db.version(9).stores version_9 + @db.version(10).stores version_10 + .upgrade (transaction) => + @logger.warn "Database upgrade to version #{@db.verno}", transaction + transaction[@OBJECT_STORE_CONVERSATION_EVENTS].toCollection().modify (event) -> + event.category = z.message.MessageCategorization.category_from_event event @db.open() .then => diff --git a/app/script/user/UserMapper.coffee b/app/script/user/UserMapper.coffee index 7ad772a827d..e00ccea9b2d 100644 --- a/app/script/user/UserMapper.coffee +++ b/app/script/user/UserMapper.coffee @@ -23,7 +23,6 @@ z.user ?= {} class z.user.UserMapper ### Construct a new User Mapper. - @param asset_service [z.assets.AssetService] Backend REST API asset service implementation ### constructor: (@asset_service) -> @@ -31,9 +30,7 @@ class z.user.UserMapper ### Converts JSON user into user entity. - @param data [Object] User data - @return [z.entity.User] Mapped user entity ### map_user_from_object: (data) -> @@ -41,9 +38,7 @@ class z.user.UserMapper ### Converts JSON self user into user entity. - @param data [Object] User data - @return [z.entity.User] Mapped user entity ### map_self_user_from_object: (data) -> @@ -62,30 +57,26 @@ class z.user.UserMapper Convert multiple JSON users into user entities. @note Return an empty array in any case to prevent crashes. - @param json [Object] User data - @return [Array] Mapped user entities ### map_users_from_object: (data) -> if data? return (@map_user_from_object user for user in data when user isnt undefined) - else - @logger.warn 'We got no user data from the backend' - return [] + @logger.warn 'We got no user data from the backend' + return [] ### Maps JSON user into a blank user entity or updates an existing one. @note Mapping of single properties to an existing user happens when the user changes his name or accent color. - @param user_et [z.entity.User] User entity that the info shall be mapped to @param data [Object] User data - @return [z.entity.User] Mapped user entity ### update_user_from_object: (user_et, data) -> return if not data? + # It's a new user if data.id? and user_et.id is '' user_et.id = data.id @@ -94,25 +85,30 @@ class z.user.UserMapper else if user_et.id isnt '' and data.id isnt user_et.id throw new Error('updating wrong user_et') + if data.accent_id? and data.accent_id isnt 0 + user_et.accent_id data.accent_id + + if data.assets?.length > 0 + @_map_profile_assets user_et, data.assets + else if data.picture?.length > 0 + @_map_profile_pictures user_et, data.picture + if data.email? user_et.email data.email - if data.phone? - user_et.phone data.phone + if data.handle? + user_et.username data.handle if data.name? user_et.name data.name.trim() - if data.handle? - user_et.username data.handle - - if data.accent_id? and data.accent_id isnt 0 - user_et.accent_id data.accent_id + if data.phone? + user_et.phone data.phone - if data.assets?.length > 0 - @_map_profile_assets user_et, data.assets - else if data.picture?.length > 0 - @_map_profile_pictures user_et, data.picture + if data.service? + user_et.is_bot = true + user_et.provider_id = data.service.provider + user_et.service_id = data.service.id return user_et diff --git a/app/script/view_model/ConversationTitlebarViewModel.coffee b/app/script/view_model/ConversationTitlebarViewModel.coffee index 4cb505a8d78..08de4c9234d 100644 --- a/app/script/view_model/ConversationTitlebarViewModel.coffee +++ b/app/script/view_model/ConversationTitlebarViewModel.coffee @@ -88,5 +88,8 @@ class z.ViewModel.ConversationTitlebarViewModel else amplify.publish z.event.WebApp.CALL.STATE.TOGGLE, @conversation_et().id, true + click_on_collection_button: -> + amplify.publish z.event.WebApp.CONTENT.SWITCH, z.ViewModel.content.CONTENT_STATE.COLLECTION + show_participants: (add_people) -> amplify.publish z.event.WebApp.PEOPLE.TOGGLE, add_people diff --git a/app/script/view_model/GiphyViewModel.coffee b/app/script/view_model/GiphyViewModel.coffee index dbae39b6a98..bfe6bd9a9bf 100644 --- a/app/script/view_model/GiphyViewModel.coffee +++ b/app/script/view_model/GiphyViewModel.coffee @@ -76,7 +76,8 @@ class z.ViewModel.GiphyViewModel if @selected_gif() and not @sending_giphy_message conversation_et = @conversation_repository.active_conversation() @sending_giphy_message = true - @conversation_repository.send_gif conversation_et, @selected_gif().animated, @query(), -> + @conversation_repository.send_gif conversation_et, @selected_gif().animated, @query() + .then => @sending_giphy_message = false event = new z.tracking.event.PictureTakenEvent 'conversation', 'giphy', 'button' amplify.publish z.event.WebApp.ANALYTICS.EVENT, event.name, event.attributes diff --git a/app/script/view_model/ImageDetailViewViewModel.coffee b/app/script/view_model/ImageDetailViewViewModel.coffee index 21d0ea27fc6..a9169481e61 100644 --- a/app/script/view_model/ImageDetailViewViewModel.coffee +++ b/app/script/view_model/ImageDetailViewViewModel.coffee @@ -22,52 +22,30 @@ z.ViewModel ?= {} class z.ViewModel.ImageDetailViewViewModel constructor: (@element_id) -> - @image_element = undefined - @button_element = undefined @image_modal = undefined - amplify.subscribe z.event.WebApp.CONVERSATION.DETAIL_VIEW.SHOW, @show_detail_view - - show_detail_view: (src) => - element = $("##{@element_id}") + @image_visible = ko.observable false + @image_src = ko.observable() - @image_element = element.find '.detail-view-image' - @image_element[0].src = src + amplify.subscribe z.event.WebApp.CONVERSATION.DETAIL_VIEW.SHOW, @show_detail_view - @button_element = element.find '.detail-view-close-button' + ko.applyBindings @, document.getElementById @element_id + show_detail_view: (src) => @image_modal.destroy() if @image_modal? - @image_modal = new zeta.webapp.module.Modal '#detail-view', @_hide_callback, @_before_hide_callback + @image_modal = new zeta.webapp.module.Modal '#detail-view', undefined, @_before_hide_callback @image_modal.show() - @_show_image() + amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.SessionEventName.INTEGER.IMAGE_DETAIL_VIEW_OPENED + + @image_src src + window.setTimeout => + @image_visible true + , 10 hide_detail_view: => @image_modal.hide() + @image_src undefined _before_hide_callback: => - @image_element.removeClass 'modal-content-anim-open' - - _hide_callback: => - $(window).off 'resize', @_center_image - - _show_image: => - amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.SessionEventName.INTEGER.IMAGE_DETAIL_VIEW_OPENED - setTimeout => - @_center_image() - @image_element - .addClass 'modal-content-anim-open' - .one z.util.alias.animationend, => @_check_close_button() - , 0 - $(window).on 'resize', @_center_image - - _check_close_button: => - rect_image = @image_element[0].getBoundingClientRect() - rect_button = @button_element[0].getBoundingClientRect() - is_overlapping = rect_button.left < rect_image.right and rect_button.bottom > rect_image.top - @button_element.toggleClass 'detail-view-close-button-fullscreen', is_overlapping - - _center_image: => - @image_element.css - 'margin-left': (window.innerWidth - @image_element.width()) / 2 - 'margin-top': (window.innerHeight - @image_element.height()) / 2 + @image_visible false diff --git a/app/script/view_model/ParticipantsViewModel.coffee b/app/script/view_model/ParticipantsViewModel.coffee index bf3273c1d6b..d7f51aacfd7 100644 --- a/app/script/view_model/ParticipantsViewModel.coffee +++ b/app/script/view_model/ParticipantsViewModel.coffee @@ -167,7 +167,8 @@ class z.ViewModel.ParticipantsViewModel @participants_bubble.hide() if @conversation().is_group() - @conversation_repository.add_members @conversation(), user_ids, => + @conversation_repository.add_members @conversation(), user_ids + .then => amplify.publish z.event.WebApp.ANALYTICS.EVENT, z.tracking.EventName.CONVERSATION.ADD_TO_GROUP_CONVERSATION, {numberOfParticipantsAdded: user_ids.length, numberOfGroupParticipants: @conversation().number_of_participants()} else diff --git a/app/script/view_model/bindings/CommonBindings.coffee b/app/script/view_model/bindings/CommonBindings.coffee index b765953dc9a..96b5ef8bd50 100644 --- a/app/script/view_model/bindings/CommonBindings.coffee +++ b/app/script/view_model/bindings/CommonBindings.coffee @@ -345,12 +345,11 @@ ko.bindingHandlers.in_viewport = do -> _dispose = -> z.util.ArrayUtil.remove_element listeners, _check_element - _check_element = _.debounce (e) -> + _check_element = -> is_child = if e? then e.target.contains(element) else true if is_child and _in_view element dispose = valueAccessor()?() _dispose() if dispose - , 300 listeners.push _check_element _check_element() diff --git a/app/script/view_model/content/CollectionDetailsViewModel.coffee b/app/script/view_model/content/CollectionDetailsViewModel.coffee new file mode 100644 index 00000000000..9dec14eef51 --- /dev/null +++ b/app/script/view_model/content/CollectionDetailsViewModel.coffee @@ -0,0 +1,54 @@ +# +# Wire +# Copyright (C) 2016 Wire Swiss GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see http://www.gnu.org/licenses/. +# + +window.z ?= {} +z.ViewModel ?= {} +z.ViewModel.content ?= {} + +# Parent: z.ViewModel.CollectionViewModel +class z.ViewModel.content.CollectionDetailsViewModel + constructor: -> + @logger = new z.util.Logger 'z.ViewModel.CollectionDetailsViewModel', z.config.LOGGER.OPTIONS + + @template = ko.observable() + @conversation_et = ko.observable() + + @items = ko.observableArray() + + set_conversation: (conversation_et, category, items) => + @template category + @conversation_et conversation_et + @push_deferred @items, items + + removed_from_view: => + @conversation_et null + @items.removeAll() + + click_on_back_button: -> + amplify.publish z.event.WebApp.CONTENT.SWITCH, z.ViewModel.content.CONTENT_STATE.COLLECTION + + # helper + push_deferred: (target, src, number = 100, delay = 300) -> + interval = window.setInterval -> + chunk = src.splice 0, number + z.util.ko_array_push_all target, chunk + + if src.length is 0 + window.clearInterval interval + + , delay diff --git a/app/script/view_model/content/CollectionViewModel.coffee b/app/script/view_model/content/CollectionViewModel.coffee new file mode 100644 index 00000000000..e1ceea26c8f --- /dev/null +++ b/app/script/view_model/content/CollectionViewModel.coffee @@ -0,0 +1,90 @@ +# +# Wire +# Copyright (C) 2016 Wire Swiss GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see http://www.gnu.org/licenses/. +# + +window.z ?= {} +z.ViewModel ?= {} +z.ViewModel.content ?= {} + +# Parent: z.ViewModel.ContentViewModel +class z.ViewModel.content.CollectionViewModel + constructor: (element_id, @conversation_repository, @collection_details) -> + @logger = new z.util.Logger 'z.ViewModel.CollectionViewModel', z.config.LOGGER.OPTIONS + + @conversation_et = ko.observable() + + @audio = ko.observableArray() + @files = ko.observableArray() + @images = ko.observableArray() + @links = ko.observableArray() + @video = ko.observableArray() + + @no_items_found = ko.observable false + + removed_from_view: => + @no_items_found false + @conversation_et null + [@images, @files, @links, @audio, @video].forEach (array) -> array.removeAll() + + set_conversation: (conversation_et) => + @conversation_et conversation_et + @conversation_repository.get_events_for_category conversation_et, z.message.MessageCategory.LINK_PREVIEW + .then (message_ets) => + if message_ets.length is 0 + @no_items_found true + else + @populate_items message_ets + + populate_items: (message_ets) => + images = [] + files = [] + audio = [] + video = [] + links = [] + + for message_et in message_ets + switch + when message_et.category & z.message.MessageCategory.IMAGE and not (message_et.category & z.message.MessageCategory.GIF) + images.push message_et + when message_et.category & z.message.MessageCategory.FILE + asset_et = message_et.get_first_asset() + switch + when asset_et.is_video() + video.push message_et + when asset_et.is_audio() + audio.push message_et + else + files.push message_et + when message_et.category & z.message.MessageCategory.LINK_PREVIEW + links.push message_et + + @images images + @files files + @audio audio + @video video + @links links + + click_on_back_button: => + amplify.publish z.event.WebApp.CONVERSATION.SHOW, @conversation_et() + + click_on_section: (category, items) => + @collection_details.set_conversation @conversation_et(), category, [].concat items + amplify.publish z.event.WebApp.CONTENT.SWITCH, z.ViewModel.content.CONTENT_STATE.COLLECTION_DETAILS + + click_on_image: (message_et, event) -> + target_element = $(event.currentTarget) + amplify.publish z.event.WebApp.CONVERSATION.DETAIL_VIEW.SHOW, target_element.find('img')[0].src diff --git a/app/script/view_model/content/ContentState.coffee b/app/script/view_model/content/ContentState.coffee index 7e84e839c4e..7326e2fb038 100644 --- a/app/script/view_model/content/ContentState.coffee +++ b/app/script/view_model/content/ContentState.coffee @@ -22,6 +22,8 @@ z.ViewModel.content ?= {} z.ViewModel.content.CONTENT_STATE = + COLLECTION: 'z.ViewModel.content.CONTENT_STATE.COLLECTION' + COLLECTION_DETAILS: 'z.ViewModel.content.CONTENT_STATE.COLLECTION_DETAILS' CONVERSATION: 'z.ViewModel.content.CONTENT_STATE.CONVERSATION' CONNECTION_REQUESTS: 'z.ViewModel.content.CONTENT_STATE.CONNECTION_REQUESTS' PREFERENCES_ABOUT: 'z.ViewModel.content.CONTENT_STATE.PREFERENCES_ABOUT' diff --git a/app/script/view_model/content/ContentViewModel.coffee b/app/script/view_model/content/ContentViewModel.coffee index 8e85d6e3b82..5d4a2985d87 100644 --- a/app/script/view_model/content/ContentViewModel.coffee +++ b/app/script/view_model/content/ContentViewModel.coffee @@ -35,13 +35,14 @@ class z.ViewModel.content.ContentViewModel # nested view models @call_shortcuts = new z.ViewModel.CallShortcutsViewModel @call_center @video_calling = new z.ViewModel.VideoCallingViewModel 'video-calling', @call_center, @conversation_repository, @media_repository, @user_repository, @multitasking + @collection_details = new z.ViewModel.content.CollectionDetailsViewModel 'collection-details' + @collection = new z.ViewModel.content.CollectionViewModel 'collection', @conversation_repository, @collection_details @connect_requests = new z.ViewModel.content.ConnectRequestsViewModel 'connect-requests', @user_repository @conversation_titlebar = new z.ViewModel.ConversationTitlebarViewModel 'conversation-titlebar', @call_center, @conversation_repository, @multitasking @conversation_input = new z.ViewModel.ConversationInputViewModel 'conversation-input', @conversation_repository, @user_repository @message_list = new z.ViewModel.MessageListViewModel 'message-list', @conversation_repository, @user_repository @participants = new z.ViewModel.ParticipantsViewModel 'participants', @user_repository, @conversation_repository, @search_repository @giphy = new z.ViewModel.GiphyViewModel 'giphy-modal', @conversation_repository, @giphy_repository - @detail_view = new z.ViewModel.ImageDetailViewViewModel 'detail-view' @preferences_account = new z.ViewModel.content.PreferencesAccountViewModel 'preferences-account', @client_repository, @user_repository @preferences_av = new z.ViewModel.content.PreferencesAVViewModel 'preferences-av', @audio_repository, @media_repository @@ -64,6 +65,8 @@ class z.ViewModel.content.ContentViewModel @preferences_av.initiate_devices() when z.ViewModel.content.CONTENT_STATE.PREFERENCES_DEVICES @preferences_devices.update_fingerprint() + when z.ViewModel.content.CONTENT_STATE.COLLECTION + @collection.set_conversation @previous_conversation else @conversation_input.removed_from_view() @conversation_titlebar.removed_from_view() @@ -156,6 +159,8 @@ class z.ViewModel.content.ContentViewModel _get_element_of_content: (content_state) -> switch content_state + when z.ViewModel.content.CONTENT_STATE.COLLECTION then '.collection' + when z.ViewModel.content.CONTENT_STATE.COLLECTION_DETAILS then '.collection-details' when z.ViewModel.content.CONTENT_STATE.CONVERSATION then '.conversation' when z.ViewModel.content.CONTENT_STATE.CONNECTION_REQUESTS then '.connect-requests' when z.ViewModel.content.CONTENT_STATE.PREFERENCES_ABOUT then '.preferences-about' diff --git a/app/script/view_model/list/ActionsViewModel.coffee b/app/script/view_model/list/ActionsViewModel.coffee index 40fd791e5ae..3c24c8b91bd 100644 --- a/app/script/view_model/list/ActionsViewModel.coffee +++ b/app/script/view_model/list/ActionsViewModel.coffee @@ -130,7 +130,8 @@ class z.ViewModel.list.ActionsViewModel click_on_unarchive_action: => @_click_on_action() .then (conversation_et) => - @conversation_repository.unarchive_conversation conversation_et, => + @conversation_repository.unarchive_conversation conversation_et + .then => if not @conversation_repository.conversations_archived().length @list_view_model.switch_list z.ViewModel.list.LIST_STATE.CONVERSATIONS diff --git a/app/style/common/content.less b/app/style/common/content.less new file mode 100644 index 00000000000..eeafa22daae --- /dev/null +++ b/app/style/common/content.less @@ -0,0 +1,73 @@ +/* + * Wire + * Copyright (C) 2016 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +.content-wrapper { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +// Titlebar +.content-titlebar { + .label-bold-xs; + .flex-center; + background-color: @app-bg; + border-bottom: 1px solid fade(@graphite, 12%); + height: @content-title-bar-height; + position: relative; + width: 100%; + flex: 0 0 auto; +} + +.content-titlebar-items-left { + align-items: center; + display: flex; + height: 100%; + position: absolute; + left: 0; + top: 0; +} + +.content-titlebar-items-center { + .flex-center; + flex-direction: column; +} + +.content-titlebar-items-right { + align-items: center; + display: flex; + height: 100%; + position: absolute; + right: 0; + top: 0; +} + +.content-titlebar-icon { + .button-icon-large; + flex: 0 0 auto; + margin-left: 12px; + margin-right: 12px; +} + +// List TODO antiscroll +.content-list { + flex: 1 1; + overflow-y: scroll +} diff --git a/app/style/components/asset/asset-header.less b/app/style/components/asset/asset-header.less new file mode 100644 index 00000000000..df25320de46 --- /dev/null +++ b/app/style/components/asset/asset-header.less @@ -0,0 +1,35 @@ +/* + * Wire + * Copyright (C) 2016 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +asset-header { + display: flex; + font-size: @font-size-xs; + justify-content: space-between; + line-height: @line-height-sm; + margin-bottom: 8px; + width: 100%; +} + +.asset-header-name { + font-weight: 500; +} + +.asset-header-time { + color: @graphite; +} diff --git a/app/style/components/asset/audio-asset.less b/app/style/components/asset/audio-asset.less index 54f9e8f7b73..cfdbd026363 100644 --- a/app/style/components/asset/audio-asset.less +++ b/app/style/components/asset/audio-asset.less @@ -19,15 +19,15 @@ audio-asset { .asset-container-style; - position: relative; - margin-top: 16px; - display: flex; - height: 64px; + padding: 12px; + flex-wrap: wrap; +} + +.audio-controls { align-items: center; - padding-left: 12px; - padding-top: 12px; - padding-bottom: 12px; - padding-right: 24px; + display: flex; + position: relative; + width: 100%; } .audio-controls-time { diff --git a/app/style/components/asset/common/common.less b/app/style/components/asset/common/common.less index 529f053cee4..a08bd2023fb 100644 --- a/app/style/components/asset/common/common.less +++ b/app/style/components/asset/common/common.less @@ -17,14 +17,32 @@ * */ +// Variables +@border-radius: 4px; + .asset-container-style { - border-radius: 4px; background-color: fade(@graphite, 12%); + display: flex; + min-height: 64px; + overflow: hidden; + position: relative; + + & + & { + margin-top: 1px; + } + + &:last-of-type { + border-bottom-left-radius: @border-radius; + border-bottom-right-radius: @border-radius; + } + + &:first-of-type { + border-top-left-radius: @border-radius; + border-top-right-radius: @border-radius; + } } .asset-placeholder { .full-screen; - align-items: center; - display: flex; - justify-content: center; + .flex-center; } diff --git a/app/style/components/asset/file-asset.less b/app/style/components/asset/file-asset.less index e6f6a87f363..7f103ab6bf7 100644 --- a/app/style/components/asset/file-asset.less +++ b/app/style/components/asset/file-asset.less @@ -17,15 +17,17 @@ * */ -// FILE -.file { +file-asset { .asset-container-style; - position: relative; margin-top: 16px; + padding: 12px; + flex-wrap: wrap; +} + +.file { + position: relative; display: flex; - height: 64px; align-items: center; - padding: 12px; } .file-icon { diff --git a/app/style/components/asset/link-preview-asset.less b/app/style/components/asset/link-preview-asset.less index 9ae6ca3f23f..78c95c9d481 100644 --- a/app/style/components/asset/link-preview-asset.less +++ b/app/style/components/asset/link-preview-asset.less @@ -35,7 +35,7 @@ link-preview-asset { .link-preview-container { .asset-container-style; - overflow: hidden; + flex-wrap: wrap; &.ephemeral-link-preview { .accent-background-color(8%); } diff --git a/app/style/components/asset/link-preview-compact-asset.less b/app/style/components/asset/link-preview-compact-asset.less new file mode 100644 index 00000000000..54c017040e2 --- /dev/null +++ b/app/style/components/asset/link-preview-compact-asset.less @@ -0,0 +1,66 @@ +/* + * Wire + * Copyright (C) 2016 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +link-preview-compact-asset { + .asset-container-style; + cursor: pointer; + width: 100%; +} + +.link-preview-compact-image-container { + .square(88px); + background-color: fade(@graphite, 24%); + flex: 0 0 auto; +} + +.link-preview-compact-image-placeholder { + .flex-center; + color: #fff; + width: 100%; + height: 100%; +} + +.link-preview-compact-image { + width: 100%; + height: 100%; +} + +.link-preview-compact-info { + flex: 1 1 auto; + display: flex; + flex-direction: column; + padding: 12px; + justify-content: center; + width: 0; +} + +.link-preview-compact-info-header { + margin-bottom: 4px; +} + +.link-preview-compact-info-title { + font-weight: 500; // semi-bold + line-height: @line-height-lg; + margin-bottom: 4px; +} + +.link-preview-compact-info-link { + font-size: @font-size-xs; + line-height: @line-height-sm; +} diff --git a/app/style/components/asset/video-asset.less b/app/style/components/asset/video-asset.less index bb3c07392ea..bb0a3cfcab1 100644 --- a/app/style/components/asset/video-asset.less +++ b/app/style/components/asset/video-asset.less @@ -25,7 +25,6 @@ video-asset { .asset-container-style; display: block; - margin-top: 16px; position: relative; &:after { diff --git a/app/style/components/image.less b/app/style/components/image.less index f22d8bd80d9..d6e62ba510f 100644 --- a/app/style/components/image.less +++ b/app/style/components/image.less @@ -1,5 +1,6 @@ image-component { .flex-center; + background-color: fade(@graphite, 12%); width: 100%; height: 100%; diff --git a/app/style/content/collection.less b/app/style/content/collection.less new file mode 100644 index 00000000000..b6065e97ded --- /dev/null +++ b/app/style/content/collection.less @@ -0,0 +1,95 @@ +/* + * Wire + * Copyright (C) 2016 Wire Swiss GmbH + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see http://www.gnu.org/licenses/. + * + */ + +// Variables +@image-size: 104px; +@image-items-per-row: 5; + +// Sections + +.collection-section { + display: inline-block; + padding-top: 24px; + margin-bottom: 16px; + + > header { + align-items: center; + display: flex; + height: 32px; + padding-bottom: 8px; + } +} + +.collection-header-icon { + text-align: center; + width: @conversation-message-sender-width; +} + +.collection-header-all { + .font-size-xs; + .cursor-pointer; + font-weight: bold; + margin-right: 0; + margin-left: auto; +} + +// Images +.collection-images { + border-radius: 4px; + display: flex; + flex-wrap: wrap; + margin-left: @conversation-message-sender-width; + overflow: hidden; + width: @image-size * @image-items-per-row; +} + +.collection-image { + .square(@image-size); + outline: 1px solid #fff; +} + +// Files +.collection-file { + border-radius: 0; + width: 100%; + margin: 0; + + & + & { + margin-top: 1px; + } +} + +// Videos +.collection-video { + flex: 1 1 auto; + height: @image-size; + outline: 1px solid #fff; +} + +// Links +.collection-link { + margin-left: @conversation-message-sender-width; +} + +// No Items +.collection-no-items-icon { + color: fade(@graphite, 40%); + font-size: 120px; + margin-bottom: 32px; +} diff --git a/app/style/content/conversation/conversation-titlebar.less b/app/style/content/conversation/conversation-titlebar.less index cb2b6996c1a..8db6eeaa2fb 100644 --- a/app/style/content/conversation/conversation-titlebar.less +++ b/app/style/content/conversation/conversation-titlebar.less @@ -35,6 +35,15 @@ padding: 8px 16px; } +.conversation-titlebar-library { + align-items: center; + display: flex; + height: 100%; + position: absolute; + left: 0; + top: 0; +} + .conversation-titlebar-icons { align-items: center; display: flex; diff --git a/app/style/content/conversation/detail-view.less b/app/style/content/conversation/detail-view.less index 225c16444f2..c91d77e50cc 100644 --- a/app/style/content/conversation/detail-view.less +++ b/app/style/content/conversation/detail-view.less @@ -20,37 +20,30 @@ .detail-view { &.modal:before { - background-color: fade(#000, 88%); + background-color: #fff; } &.modal-show { - display: block; + .flex-center } } .detail-view-image { - position: absolute; max-width: 84%; max-height: 84%; - height: auto; width: auto; } .detail-view-close-button { .circle(40px); - display: flex; - justify-content: center; - align-items: center; + .flex-center; + background-color: fade(#fff, 24%); position: absolute; right: 24px; top: 24px; cursor: pointer; - color: #fff; > span { position: relative; } } -.detail-view-close-button-fullscreen { - background-color: fade(#000, 88%); -} diff --git a/app/style/content/conversation/message-list.less b/app/style/content/conversation/message-list.less index a7163d766e6..75972a06eb7 100644 --- a/app/style/content/conversation/message-list.less +++ b/app/style/content/conversation/message-list.less @@ -494,8 +494,18 @@ padding-top: 16px; } +.message-asset { + margin-top: 16px; +} + // TODO make generic class .ephemeral-message-obfuscated { font-family: @font-family-ephemeral; .accent-color(); } + +.ephemeral-asset-expired { + .bg-color-ephemeral; + .flex-center; + color: #fff; +} diff --git a/app/style/fonts/zeta-neue.css b/app/style/fonts/zeta-neue.css index d96b435ce96..a5052e879f6 100755 --- a/app/style/fonts/zeta-neue.css +++ b/app/style/fonts/zeta-neue.css @@ -19,7 +19,7 @@ @font-face { font-family: 'Wire'; - src: url('/font/Wire.ttf?8t0ru7') format('truetype'); + src: url('/font/Wire.ttf?2m763t') format('truetype'); font-weight: normal; font-style: normal; } @@ -243,6 +243,9 @@ .icon-hourglass:before { content: "\e239"; } +.icon-collection:before { + content: "\e260"; +} .icon-av:before { content: "\e261"; } diff --git a/app/style/main.less b/app/style/main.less index a0d3f91ab7d..3733f3447d4 100644 --- a/app/style/main.less +++ b/app/style/main.less @@ -35,6 +35,7 @@ @import "common/checkmark"; @import "common/colors"; @import "common/common"; +@import "common/content"; @import "common/hide-controls"; @import "common/hint"; @import "common/input"; @@ -70,10 +71,12 @@ @import "components/ephemeral-timer"; @import "components/input-level"; @import "components/asset/common/common.less"; +@import "components/asset/asset-header"; @import "components/asset/video-asset"; @import "components/asset/file-asset"; @import "components/asset/audio-asset"; @import "components/asset/link-preview-asset"; +@import "components/asset/link-preview-compact-asset"; @import "components/asset/location-asset"; @import "components/asset/controls/seek-bar"; @import "components/asset/controls/audio-seek-bar"; @@ -89,6 +92,7 @@ @import "content/conversation/detail-view"; @import "content/conversation/giphy"; +@import "content/collection"; @import "content/conversation"; @import "content/connect-requests"; @import "content/preferences"; diff --git a/package.json b/package.json index 24b441ea109..3d1c0f7028e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "wire-webapp", "license": "LicenseRef-LICENSE", "devDependencies": { - "autoprefixer": "6.6.0", + "autoprefixer": "6.6.1", "coffeelint": "1.16.0", "grunt": "1.0.1", "grunt-aws-s3": "0.14.5", diff --git a/test/unit_tests/conversation/ConversationServiceNoCompoundSpec.coffee b/test/unit_tests/conversation/ConversationServiceNoCompoundSpec.coffee new file mode 100644 index 00000000000..5f97181ae37 --- /dev/null +++ b/test/unit_tests/conversation/ConversationServiceNoCompoundSpec.coffee @@ -0,0 +1,267 @@ +# +# Wire +# Copyright (C) 2016 Wire Swiss GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see http://www.gnu.org/licenses/. +# + +# grunt test_init && grunt test_run:conversation/ConversationServiceNoCompound + +describe 'z.conversation.ConversationServiceNoCompound', -> + conversation_mapper = null + conversation_service = null + server = null + storage_service = null + test_factory = new TestFactory() + + beforeAll (done) -> + test_factory.exposeStorageActors() + .then (storage_repository) -> + client = test_factory.client + storage_service = storage_repository.storage_service + conversation_service = new z.conversation.ConversationServiceNoCompound client, storage_service + + conversation_mapper = new z.conversation.ConversationMapper() + server = sinon.fakeServer.create() + done() + .catch done.fail + + afterEach -> + storage_service.clear_all_stores() + server.restore() + + describe 'load_events_from_db', -> + + conversation_id = '35a9a89d-70dc-4d9e-88a2-4d8758458a6a' + sender_id = '8b497692-7a38-4a5d-8287-e3d1006577d6' + + # @formatter:off + messages = [ + { + key: "#{conversation_id}@#{sender_id}@1470317275182" + object: {"conversation":"35a9a89d-70dc-4d9e-88a2-4d8758458a6a","id":"68a28ab1-d7f8-4014-8b52-5e99a05ea3b1","from":"8b497692-7a38-4a5d-8287-e3d1006577d6","time":"2016-08-04T13:27:55.182Z","data":{"content":"First message","nonce":"68a28ab1-d7f8-4014-8b52-5e99a05ea3b1","previews":[]},"type":"conversation.message-add"} + }, + { + key: "#{conversation_id}@#{sender_id}@1470317278993" + object: {"conversation":"35a9a89d-70dc-4d9e-88a2-4d8758458a6a","id":"4af67f76-09f9-4831-b3a4-9df877b8c29a","from":"8b497692-7a38-4a5d-8287-e3d1006577d6","time":"2016-08-04T13:27:58.993Z","data":{"content":"Second message","nonce":"4af67f76-09f9-4831-b3a4-9df877b8c29a","previews":[]},"type":"conversation.message-add"} + } + ] + # @formatter:on + + beforeEach (done) -> + Promise.all messages.map (message) -> + return storage_service.save storage_service.OBJECT_STORE_CONVERSATION_EVENTS, message.key, message.object + .then done + .catch done.fail + + it 'returns mapped message_et if event with id is found', (done) -> + conversation_service.load_event_from_db conversation_id, '4af67f76-09f9-4831-b3a4-9df877b8c29a' + .then (message_et) => + expect(message_et).toEqual messages[1].object + done() + .catch done.fail + + it 'returns undefined if no event with id is found', (done) -> + conversation_service.load_event_from_db conversation_id, z.util.create_random_uuid() + .then (message_et) => + expect(message_et).not.toBeDefined() + done() + .catch done.fail + + describe 'update_message_in_db', -> + # @formatter:off + event = {"conversation":"35a9a89d-70dc-4d9e-88a2-4d8758458a6a","id":"4af67f76-09f9-4831-b3a4-9df877b8c29a","from":"8b497692-7a38-4a5d-8287-e3d1006577d6","time":"2016-08-04T13:27:58.993Z","data":{"content":"Second message","nonce":"4af67f76-09f9-4831-b3a4-9df877b8c29a","previews":[]},"type":"conversation.message-add"} + # @formatter:on + + it 'updated event in the database', (done) -> + event.time = new Date().toISOString() + conversation_service.update_message_in_db event, {time: event.time} + .then done + .catch done.fail + + it 'fails if changes are not specified', (done) -> + conversation_service.update_message_in_db event, undefined + .then done.fail + .catch (error) -> + expect(error).toEqual jasmine.any z.conversation.ConversationError + expect(error.type).toBe z.conversation.ConversationError::TYPE.NO_CHANGES + done() + + describe 'load_events_from_db', -> + conversation_id = '35a9a89d-70dc-4d9e-88a2-4d8758458a6a' + sender_id = '8b497692-7a38-4a5d-8287-e3d1006577d6' + messages = undefined + + beforeEach (done) -> + timestamp = 1479903546799 + messages = [0...10].map (index) -> + return { + key: "#{conversation_id}@#{sender_id}@#{index}" + object: {"conversation": conversation_id, "time": new Date(timestamp + index).toISOString()} + } + + Promise.all messages.map (message) -> + return storage_service.save storage_service.OBJECT_STORE_CONVERSATION_EVENTS, message.key, message.object + .then done + .catch done.fail + + it 'doesn\'t load events for invalid conversation id', (done) -> + conversation_service.load_events_from_db 'invalid_id', new Date(30), new Date 1479903546808 + .then (events) => + expect(events.length).toBe 0 + done() + + it 'loads all events', (done) -> + conversation_service.load_events_from_db conversation_id + .then (events) => + expect(events.length).toBe 10 + expect(events[0].time).toBe '2016-11-23T12:19:06.808Z' + expect(events[9].time).toBe '2016-11-23T12:19:06.799Z' + done() + + it 'loads all events with limit', (done) -> + conversation_service.load_events_from_db conversation_id, undefined, undefined, 5 + .then (events) => + expect(events.length).toBe 5 + expect(events[0].time).toBe '2016-11-23T12:19:06.808Z' + expect(events[4].time).toBe '2016-11-23T12:19:06.804Z' + done() + + it 'loads events with lower bound', (done) -> + conversation_service.load_events_from_db conversation_id, new Date 1479903546805 + .then (events) => + expect(events.length).toBe 4 + expect(events[0].time).toBe '2016-11-23T12:19:06.808Z' + expect(events[1].time).toBe '2016-11-23T12:19:06.807Z' + expect(events[2].time).toBe '2016-11-23T12:19:06.806Z' + expect(events[3].time).toBe '2016-11-23T12:19:06.805Z' + done() + + it 'loads events with upper bound', (done) -> + conversation_service.load_events_from_db conversation_id, undefined, new Date 1479903546803 + .then (events) => + expect(events.length).toBe 4 + expect(events[0].time).toBe '2016-11-23T12:19:06.802Z' + expect(events[1].time).toBe '2016-11-23T12:19:06.801Z' + expect(events[2].time).toBe '2016-11-23T12:19:06.800Z' + expect(events[3].time).toBe '2016-11-23T12:19:06.799Z' + done() + + it 'loads events with upper and lower bound', (done) -> + conversation_service.load_events_from_db conversation_id, new Date(1479903546806), new Date 1479903546807 + .then (events) => + expect(events.length).toBe 1 + expect(events[0].time).toBe '2016-11-23T12:19:06.806Z' + done() + + it 'loads events with upper and lower bound and a fetch limit', (done) -> + conversation_service.load_events_from_db conversation_id, new Date(1479903546800), new Date(1479903546807), 2 + .then (events) => + expect(events.length).toBe 2 + expect(events[0].time).toBe '2016-11-23T12:19:06.806Z' + expect(events[1].time).toBe '2016-11-23T12:19:06.805Z' + done() + + describe 'save_conversation_in_db', -> + it 'saves a conversation', (done) -> + # @formatter:off + conversation_payload = {"access":["private"],"creator":"0410795a-58dc-40d8-b216-cbc2360be21a","members":{"self":{"hidden_ref":null,"status":0,"last_read":"24fe.800122000b16c279","muted_time":null,"otr_muted_ref":null,"muted":false,"status_time":"2014-12-03T18:39:12.319Z","hidden":false,"status_ref":"0.0","id":"532af01e-1e24-4366-aacf-33b67d4ee376","otr_archived":false,"cleared":null,"otr_muted":false,"otr_archived_ref":"2016-07-25T11:30:07.883Z","archived":null},"others":[{"status":0,"id":"0410795a-58dc-40d8-b216-cbc2360be21a"}]},"name":"Michael","id":"573b6978-7700-443e-9ce5-ff78b35ac590","type":2,"last_event_time":"2016-06-21T22:53:41.778Z","last_event":"24fe.800122000b16c279"} + # @formatter:on + conversation_et = conversation_mapper.map_conversation conversation_payload + conversation_service.save_conversation_state_in_db conversation_et + .then (conversation_record) => + expect(conversation_record.name()).toBe conversation_payload.name + done() + + describe 'delete_message_with_key_from_db', -> + + conversation_id = '35a9a89d-70dc-4d9e-88a2-4d8758458a6a' + sender_id = '8b497692-7a38-4a5d-8287-e3d1006577d6' + + # @formatter:off + messages = [ + { + key: "#{conversation_id}@#{sender_id}@1470317275182" + object: {"conversation":"35a9a89d-70dc-4d9e-88a2-4d8758458a6a","id":"68a28ab1-d7f8-4014-8b52-5e99a05ea3b1","from":"8b497692-7a38-4a5d-8287-e3d1006577d6","time":"2016-08-04T13:27:55.182Z","data":{"content":"First message","nonce":"68a28ab1-d7f8-4014-8b52-5e99a05ea3b1","previews":[]},"type":"conversation.message-add"} + }, + { + key: "#{conversation_id}@#{sender_id}@1470317278993" + object: {"conversation":"35a9a89d-70dc-4d9e-88a2-4d8758458a6a","id":"4af67f76-09f9-4831-b3a4-9df877b8c29a","from":"8b497692-7a38-4a5d-8287-e3d1006577d6","time":"2016-08-04T13:27:58.993Z","data":{"content":"Second message","nonce":"4af67f76-09f9-4831-b3a4-9df877b8c29a","previews":[]},"type":"conversation.message-add"} + }, + + { + key: "#{conversation_id}@#{sender_id}@1470317278994" + object: {"conversation":"35a9a89d-70dc-4d9e-88a2-4d8758458a6a","id":"4af67f76-09f9-4831-b3a4-9df877b8c29a","from":"8b497692-7a38-4a5d-8287-e3d1006577d6","time":"2016-08-04T13:27:58.993Z","data":{"content":"Second message (Duplicate)","nonce":"4af67f76-09f9-4831-b3a4-9df877b8c29a","previews":[]},"type":"conversation.message-add"} + } + ] + # @formatter:on + + beforeEach (done) -> + Promise.all messages.map (message) -> + return storage_service.save storage_service.OBJECT_STORE_CONVERSATION_EVENTS, message.key, message.object + .then done + .catch done.fail + + it 'deletes message with the given key', (done) -> + conversation_service.delete_message_with_key_from_db conversation_id, messages[1].key + .then -> + conversation_service.load_events_from_db conversation_id + .then (events) -> + expect(events.length).toBe 2 + for event in events + expect(event.data.content).not.toBe messages[1].object.data.content + done() + .catch done.fail + + it 'does not delete the event if key is wrong', (done) -> + conversation_service.delete_message_with_key_from_db conversation_id, 'wrongKey' + .then -> + conversation_service.load_events_from_db conversation_id + .then (events) -> + expect(events.length).toBe 3 + done() + .catch done.fail + + describe 'load_events_with_category_from_db', -> + + events = undefined + + beforeEach -> + events = [ + {"conversation":"34e7f58e-b834-4d84-b628-b89b295d46c0","id":"b6498d81-92e8-4da7-afd2-054239595da7","from":"9b47476f-974d-481c-af64-13f82ed98a5f","time":"2017-01-09T13:11:15.632Z","status":2,"data":{"content":"test","nonce":"b6498d81-92e8-4da7-afd2-054239595da7","previews":[]},"type":"conversation.message-add","category": 16} + {"conversation":"34e7f58e-b834-4d84-b628-b89b295d46c0","id":"da7930dd-4c30-4378-846d-b29e1452bdfb","from":"9b47476f-974d-481c-af64-13f82ed98a5f","time":"2017-01-09T13:37:31.941Z","status":1,"data":{"content_length":47527,"content_type":"image/jpeg","id":"b77e8639-a32d-4ba7-88b9-7a0ae461e90d","info":{"tag":"medium","width":1448,"height":905,"nonce":"b77e8639-a32d-4ba7-88b9-7a0ae461e90d"},"otr_key":{},"sha256":{}},"type":"conversation.asset-add","category": 128} + {"conversation":"34e7f58e-b834-4d84-b628-b89b295d46c0","id":"da7930dd-4c30-4378-846d-b29e1452bdfa","from":"9b47476f-974d-481c-af64-13f82ed98a5f","time":"2017-01-09T13:47:31.941Z","status":1,"data":{"content_length":47527,"content_type":"image/jpeg","id":"b77e8639-a32d-4ba7-88b9-7a0ae461e90d","info":{"tag":"medium","width":1448,"height":905,"nonce":"b77e8639-a32d-4ba7-88b9-7a0ae461e90d"},"otr_key":{},"sha256":{}},"type":"conversation.asset-add","category": 128} + ] + + it 'should return no entry matches the given category', (done) -> + Promise.all events.slice(0,1).map (event) -> + return storage_service.save storage_service.OBJECT_STORE_CONVERSATION_EVENTS, z.storage.StorageService.construct_primary_key(event), event + .then -> + return conversation_service.load_events_with_category_from_db events[0].conversation, z.message.MessageCategory.IMAGE + .then (result) -> + expect(result.length).toBe 0 + done() + .catch done.fail + + it 'should get images in the correct order', (done) -> + Promise.all events.map (event) -> + return storage_service.save storage_service.OBJECT_STORE_CONVERSATION_EVENTS, z.storage.StorageService.construct_primary_key(event), event + .then -> + return conversation_service.load_events_with_category_from_db events[0].conversation, z.message.MessageCategory.IMAGE + .then (result) -> + expect(result.length).toBe 2 + expect(result[0].id).toBe events[1].id + expect(result[1].id).toBe events[2].id + done() + .catch done.fail diff --git a/test/unit_tests/conversation/ConversationServiceSpec.coffee b/test/unit_tests/conversation/ConversationServiceSpec.coffee index dd8dce4e4d6..a9edf49e1d8 100644 --- a/test/unit_tests/conversation/ConversationServiceSpec.coffee +++ b/test/unit_tests/conversation/ConversationServiceSpec.coffee @@ -232,3 +232,36 @@ describe 'z.conversation.ConversationService', -> expect(events.length).toBe 3 done() .catch done.fail + + describe 'load_events_with_category_from_db', -> + + events = undefined + + beforeEach -> + events = [ + {"conversation":"34e7f58e-b834-4d84-b628-b89b295d46c0","id":"b6498d81-92e8-4da7-afd2-054239595da7","from":"9b47476f-974d-481c-af64-13f82ed98a5f","time":"2017-01-09T13:11:15.632Z","status":2,"data":{"content":"test","nonce":"b6498d81-92e8-4da7-afd2-054239595da7","previews":[]},"type":"conversation.message-add","category": 16} + {"conversation":"34e7f58e-b834-4d84-b628-b89b295d46c0","id":"da7930dd-4c30-4378-846d-b29e1452bdfb","from":"9b47476f-974d-481c-af64-13f82ed98a5f","time":"2017-01-09T13:37:31.941Z","status":1,"data":{"content_length":47527,"content_type":"image/jpeg","id":"b77e8639-a32d-4ba7-88b9-7a0ae461e90d","info":{"tag":"medium","width":1448,"height":905,"nonce":"b77e8639-a32d-4ba7-88b9-7a0ae461e90d"},"otr_key":{},"sha256":{}},"type":"conversation.asset-add","category": 128} + {"conversation":"34e7f58e-b834-4d84-b628-b89b295d46c0","id":"da7930dd-4c30-4378-846d-b29e1452bdfa","from":"9b47476f-974d-481c-af64-13f82ed98a5f","time":"2017-01-09T13:47:31.941Z","status":1,"data":{"content_length":47527,"content_type":"image/jpeg","id":"b77e8639-a32d-4ba7-88b9-7a0ae461e90d","info":{"tag":"medium","width":1448,"height":905,"nonce":"b77e8639-a32d-4ba7-88b9-7a0ae461e90d"},"otr_key":{},"sha256":{}},"type":"conversation.asset-add","category": 128} + ] + + it 'should return no entry matches the given category', (done) -> + Promise.all events.slice(0,1).map (event) -> + return storage_service.save storage_service.OBJECT_STORE_CONVERSATION_EVENTS, z.storage.StorageService.construct_primary_key(event), event + .then -> + return conversation_service.load_events_with_category_from_db events[0].conversation, z.message.MessageCategory.IMAGE + .then (result) -> + expect(result.length).toBe 0 + done() + .catch done.fail + + it 'should get images in the correct order', (done) -> + Promise.all events.map (event) -> + return storage_service.save storage_service.OBJECT_STORE_CONVERSATION_EVENTS, z.storage.StorageService.construct_primary_key(event), event + .then -> + return conversation_service.load_events_with_category_from_db events[0].conversation, z.message.MessageCategory.IMAGE + .then (result) -> + expect(result.length).toBe 2 + expect(result[0].id).toBe events[1].id + expect(result[1].id).toBe events[2].id + done() + .catch done.fail diff --git a/test/unit_tests/message/MessageCategorizationSpec.coffee b/test/unit_tests/message/MessageCategorizationSpec.coffee new file mode 100644 index 00000000000..e8715ecef6a --- /dev/null +++ b/test/unit_tests/message/MessageCategorizationSpec.coffee @@ -0,0 +1,79 @@ +# +# Wire +# Copyright (C) 2016 Wire Swiss GmbH +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see http://www.gnu.org/licenses/. +# + +# grunt test_init && grunt test_run:message/MessageCategorization + +describe 'z.message.MessageCategorization', -> + + describe 'category_from_event', -> + + it 'malformed events should have category of type UNDEFINED', -> + expect(z.message.MessageCategorization.category_from_event()).toBe z.message.MessageCategory.UNDEFINED + + it 'ephemeral message should have category of type NONE', -> + event = '{"conversation":"34e7f58e-b834-4d84-b628-b89b295d46c0","id":"0004ebd7-1ba9-4747-b880-e63504595cc7","from":"9b47476f-974d-481c-af64-13f82ed98a5f","time":"2017-01-09T13:31:01.003Z","status":2,"data":{"content":"test","nonce":"0004ebd7-1ba9-4747-b880-e63504595cc7","previews":[]},"type":"conversation.message-add","ephemeral_expires":"1483968961027","ephemeral_started":"1483968661027"}' + category = z.message.MessageCategorization.category_from_event JSON.parse event + expect(category).toBe z.message.MessageCategory.NONE + + it 'expired message should have category of type NONE', -> + event = '{"conversation":"c499f282-2d79-4188-9808-8b63444194f8","id":"9ba0f061-0159-492b-8e6f-ba31d37ad962","from":"9b47476f-974d-481c-af64-13f82ed98a5f","time":"2017-01-09T13:07:47.096Z","status":2,"data":{"content":"aqgxjp","nonce":"9ba0f061-0159-492b-8e6f-ba31d37ad962"},"type":"conversation.message-add","ephemeral_expires":true,"ephemeral_started":"1483967267134"}' + category = z.message.MessageCategorization.category_from_event JSON.parse event + expect(category).toBe z.message.MessageCategory.NONE + + it 'text message should have category of type TEXT', -> + event = '{"conversation":"34e7f58e-b834-4d84-b628-b89b295d46c0","id":"b6498d81-92e8-4da7-afd2-054239595da7","from":"9b47476f-974d-481c-af64-13f82ed98a5f","time":"2017-01-09T13:11:15.632Z","status":2,"data":{"content":"test","nonce":"b6498d81-92e8-4da7-afd2-054239595da7","previews":[]},"type":"conversation.message-add"}' + category = z.message.MessageCategorization.category_from_event JSON.parse event + expect(category).toBe z.message.MessageCategory.TEXT + + it 'text message with link should have category of type TEXT and LINK', -> + event = '{"conversation":"34e7f58e-b834-4d84-b628-b89b295d46c0","id":"f7adaa16-38f5-483e-b621-72ff1dbd2276","from":"5598f954-674f-4a34-ad47-9e5ee8f00bcd","time":"2017-01-09T13:11:15.051Z","data":{"content":"https://wire.com","nonce":"f7adaa16-38f5-483e-b621-72ff1dbd2276","previews":["CjZodHRwczovL3dpcmUuY29tLz81ZDczNDQ0OC00NDZiLTRmYTItYjMwMy1lYTJhNzhiY2NhMDgQABpWCjZodHRwczovL3dpcmUuY29tLz81ZDczNDQ0OC00NDZiLTRmYTItYjMwMy1lYTJhNzhiY2NhMDgSHFdpcmUgwrcgTW9kZXJuIGNvbW11bmljYXRpb24="]},"type":"conversation.message-add"}' + category = z.message.MessageCategorization.category_from_event JSON.parse event + expect(category & z.message.MessageCategory.TEXT).toBe z.message.MessageCategory.TEXT + expect(category & z.message.MessageCategory.LINK).toBe z.message.MessageCategory.LINK + + it 'text message with like should have category of type TEXT and LIKED', -> + event = '{"conversation":"34e7f58e-b834-4d84-b628-b89b295d46c0","id":"b2a9bf4f-f912-4c0c-9f8b-aea290fe53e3","from":"9b47476f-974d-481c-af64-13f82ed98a5f","time":"2017-01-09T13:53:27.965Z","status":2,"data":{"content":"test","nonce":"b2a9bf4f-f912-4c0c-9f8b-aea290fe53e3","previews":[]},"type":"conversation.message-add","reactions":{"9b47476f-974d-481c-af64-13f82ed98a5f":"❤️"},"version":2}' + category = z.message.MessageCategorization.category_from_event JSON.parse event + expect(category & z.message.MessageCategory.TEXT).toBe z.message.MessageCategory.TEXT + expect(category & z.message.MessageCategory.LIKED).toBe z.message.MessageCategory.LIKED + + it 'image message should have category of type IMAGE', -> + event = '{"conversation":"34e7f58e-b834-4d84-b628-b89b295d46c0","id":"da7930dd-4c30-4378-846d-b29e1452bdfb","from":"9b47476f-974d-481c-af64-13f82ed98a5f","time":"2017-01-09T13:37:31.941Z","status":1,"data":{"content_length":47527,"content_type":"image/jpeg","id":"b77e8639-a32d-4ba7-88b9-7a0ae461e90d","info":{"tag":"medium","width":1448,"height":905,"nonce":"b77e8639-a32d-4ba7-88b9-7a0ae461e90d"},"otr_key":{},"sha256":{}},"type":"conversation.asset-add"}' + category = z.message.MessageCategorization.category_from_event JSON.parse event + expect(category).toBe z.message.MessageCategory.IMAGE + + it 'image (gif) message should have category of type IMAGE and GIF', -> + event = '{"conversation":"34e7f58e-b834-4d84-b628-b89b295d46c0","id":"1846af80-7755-4b61-885d-4e37ce77e5ff","from":"9b47476f-974d-481c-af64-13f82ed98a5f","time":"2017-01-09T13:41:50.170Z","status":2,"data":{"content_length":9953127,"content_type":"image/gif","id":"8cc946e4-e450-47c0-87a8-584d5c18b79b","info":{"tag":"medium","width":450,"height":450,"nonce":"8cc946e4-e450-47c0-87a8-584d5c18b79b"},"otr_key":{},"sha256":{}},"type":"conversation.asset-add"}' + category = z.message.MessageCategorization.category_from_event JSON.parse event + expect(category & z.message.MessageCategory.IMAGE).toBe z.message.MessageCategory.IMAGE + expect(category & z.message.MessageCategory.GIF).toBe z.message.MessageCategory.GIF + + it 'file message should have category of type FILE', -> + event = '{"conversation":"34e7f58e-b834-4d84-b628-b89b295d46c0","id":"95377495-d203-4071-a02a-5221b75644fa","from":"9b47476f-974d-481c-af64-13f82ed98a5f","time":"2017-01-09T13:46:14.855Z","status":2,"data":{"content_length":199580,"content_type":"image/jpeg","info":{"name":"6642.jpg","nonce":"95377495-d203-4071-a02a-5221b75644fa"},"id":"aed78bfd-7c98-475b-badd-2c11fd150a63","otr_key":{},"sha256":{},"status":"uploaded"},"type":"conversation.asset-meta"}' + category = z.message.MessageCategorization.category_from_event JSON.parse event + expect(category).toBe z.message.MessageCategory.FILE + + it 'ping message should have category of type KNOCK', -> + event = '{"conversation":"34e7f58e-b834-4d84-b628-b89b295d46c0","id":"6cb452b4-6ae3-496d-90a8-8d7af6d756c8","from":"9b47476f-974d-481c-af64-13f82ed98a5f","time":"2017-01-09T13:51:00.960Z","status":2,"data":{"nonce":"6cb452b4-6ae3-496d-90a8-8d7af6d756c8"},"type":"conversation.knock"}' + category = z.message.MessageCategorization.category_from_event JSON.parse event + expect(category).toBe z.message.MessageCategory.KNOCK + + it 'location message should have category of type LOCATION', -> + event = '{"conversation":"34e7f58e-b834-4d84-b628-b89b295d46c0","id":"ae551ad3-2ca5-4d3a-8eec-ef9985996c29","from":"9b47476f-974d-481c-af64-13f82ed98a5f","time":"2017-01-09T13:54:00.960","data":{"location":{"longitude":13,"latitude":52,"name":"Alexanderplatz","zoom":20},"nonce":"ae551ad3-2ca5-4d3a-8eec-ef9985996c29"},"type":"conversation.location"}' + category = z.message.MessageCategorization.category_from_event JSON.parse event + expect(category).toBe z.message.MessageCategory.LOCATION diff --git a/yarn.lock b/yarn.lock index 5ef16448a59..98fc56233db 100644 --- a/yarn.lock +++ b/yarn.lock @@ -226,15 +226,15 @@ asynckit@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" -autoprefixer@6.6.0: - version "6.6.0" - resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-6.6.0.tgz#d5b347ebbaf79e79d30b81c0ee3e482b288527bf" +autoprefixer@6.6.1: + version "6.6.1" + resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-6.6.1.tgz#11a4077abb4b313253ec2f6e1adb91ad84253519" dependencies: browserslist "~1.5.1" - caniuse-db "^1.0.30000602" + caniuse-db "^1.0.30000604" normalize-range "^0.1.2" num2fraction "^1.2.2" - postcss "^5.2.6" + postcss "^5.2.8" postcss-value-parser "^3.2.3" aws-sdk@2.0.x: @@ -535,10 +535,14 @@ camelcase@^2.0.0: version "2.1.1" resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-2.1.1.tgz#7c1d16d679a1bbe59ca02cacecfb011e201f5a1f" -caniuse-db@^1.0.30000601, caniuse-db@^1.0.30000602: +caniuse-db@^1.0.30000601: version "1.0.30000602" resolved "https://registry.yarnpkg.com/caniuse-db/-/caniuse-db-1.0.30000602.tgz#06b2cbfb6c3aeef7ddb18cd588043549ad1a2d4e" +caniuse-db@^1.0.30000604: + version "1.0.30000605" + resolved "https://registry.yarnpkg.com/caniuse-db/-/caniuse-db-1.0.30000605.tgz#c616e335a6df02f865af5e02add72c897cb6579d" + cardinal@0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/cardinal/-/cardinal-0.4.0.tgz#7d10aafb20837bde043c45e43a0c8c28cdaae45e" @@ -2843,9 +2847,9 @@ postcss@^5.0.0: source-map "^0.5.6" supports-color "^3.1.2" -postcss@^5.2.6: - version "5.2.6" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-5.2.6.tgz#a252cd67cd52585035f17e9ad12b35137a7bdd9e" +postcss@^5.2.8: + version "5.2.9" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-5.2.9.tgz#282a644f92d4b871ade2d3ce8bd0ea46f18317b6" dependencies: chalk "^1.1.3" js-base64 "^2.1.9"