From f42fedf8895416bd2c39d8c2227a39fe150e8997 Mon Sep 17 00:00:00 2001 From: Elias Nahum Date: Fri, 23 Nov 2018 12:57:28 -0300 Subject: [PATCH] Add pinned post support (#2364) * Add pinned post * Update pinned posts assets --- app/components/no_results.js | 17 +- app/components/post_list/post_list_base.js | 2 +- .../slide_up_panel/slide_up_panel.js | 6 +- app/screens/channel_info/channel_info.js | 148 +++++---- app/screens/channel_info/channel_info_row.js | 2 +- app/screens/index.js | 1 + app/screens/pinned_posts/index.js | 51 ++++ app/screens/pinned_posts/pinned_posts.js | 288 ++++++++++++++++++ app/screens/post_options/index.js | 11 +- app/screens/post_options/post_option.js | 2 + app/screens/post_options/post_options.js | 52 +++- assets/base/i18n/en.json | 9 +- assets/base/images/channel_info/pin.png | Bin 0 -> 328 bytes assets/base/images/channel_info/pin@2x.png | Bin 0 -> 900 bytes assets/base/images/channel_info/pin@3x.png | Bin 0 -> 1334 bytes assets/base/images/no_results/pin.png | Bin 0 -> 907 bytes assets/base/images/no_results/pin@2x.png | Bin 0 -> 1959 bytes assets/base/images/no_results/pin@3x.png | Bin 0 -> 2650 bytes 18 files changed, 519 insertions(+), 70 deletions(-) create mode 100644 app/screens/pinned_posts/index.js create mode 100644 app/screens/pinned_posts/pinned_posts.js create mode 100644 assets/base/images/channel_info/pin.png create mode 100644 assets/base/images/channel_info/pin@2x.png create mode 100644 assets/base/images/channel_info/pin@3x.png create mode 100644 assets/base/images/no_results/pin.png create mode 100644 assets/base/images/no_results/pin@2x.png create mode 100644 assets/base/images/no_results/pin@3x.png diff --git a/app/components/no_results.js b/app/components/no_results.js index a165c1410..c8699231e 100644 --- a/app/components/no_results.js +++ b/app/components/no_results.js @@ -3,7 +3,7 @@ import React, {PureComponent} from 'react'; import PropTypes from 'prop-types'; -import {Text, View} from 'react-native'; +import {Image, Text, View} from 'react-native'; import IonIcon from 'react-native-vector-icons/Ionicons'; import {changeOpacity, makeStyleSheetFromTheme} from 'app/utils/theme'; @@ -12,6 +12,7 @@ export default class NoResults extends PureComponent { static propTypes = { description: PropTypes.string, iconName: PropTypes.string, + image: PropTypes.number, theme: PropTypes.object.isRequired, title: PropTypes.string.isRequired, }; @@ -20,16 +21,24 @@ export default class NoResults extends PureComponent { const { description, iconName, + image, theme, title, } = this.props; const style = getStyleFromTheme(theme); let icon; - if (iconName) { + if (image) { + icon = ( + + ); + } else if (iconName) { icon = ( @@ -60,7 +69,7 @@ const getStyleFromTheme = makeStyleSheetFromTheme((theme) => { color: changeOpacity(theme.centerChannelColor, 0.4), fontSize: 20, fontWeight: '600', - marginBottom: 15, + marginVertical: 15, }, description: { color: changeOpacity(theme.centerChannelColor, 0.4), diff --git a/app/components/post_list/post_list_base.js b/app/components/post_list/post_list_base.js index 119392de3..3c51c4188 100644 --- a/app/components/post_list/post_list_base.js +++ b/app/components/post_list/post_list_base.js @@ -37,7 +37,7 @@ export default class PostListBase extends PureComponent { onPostPress: PropTypes.func, onRefresh: PropTypes.func, postIds: PropTypes.array.isRequired, - refreshing: PropTypes.bool.isRequired, + refreshing: PropTypes.bool, renderFooter: PropTypes.oneOfType([PropTypes.func, PropTypes.object]), renderReplies: PropTypes.bool, serverURL: PropTypes.string.isRequired, diff --git a/app/components/slide_up_panel/slide_up_panel.js b/app/components/slide_up_panel/slide_up_panel.js index db2ac5a9a..270a028ac 100644 --- a/app/components/slide_up_panel/slide_up_panel.js +++ b/app/components/slide_up_panel/slide_up_panel.js @@ -23,6 +23,7 @@ const CONTAINER_MARGIN = TOP_MARGIN - 10; export default class SlideUpPanel extends PureComponent { static propTypes = { + allowStayMiddle: PropTypes.bool, alwaysCaptureContainerMove: PropTypes.bool, containerHeight: PropTypes.number, children: PropTypes.oneOfType([ @@ -36,6 +37,7 @@ export default class SlideUpPanel extends PureComponent { }; static defaultProps = { + allowStayMiddle: true, headerHeight: 0, initialPosition: 0.5, marginFromTop: TOP_MARGIN, @@ -156,14 +158,14 @@ export default class SlideUpPanel extends PureComponent { }; startAnimation = (initialY, positionY, isGoingDown, initial = false) => { - const {containerHeight, onRequestClose} = this.props; + const {allowStayMiddle, containerHeight, onRequestClose} = this.props; const {finalPosition, initialPosition} = this.state; const position = new Animated.Value(initial ? initialY : positionY); let endPosition = (!isGoingDown && !initial ? finalPosition : positionY); position.removeAllListeners(); if (isGoingDown) { - if (positionY <= this.state.initialPosition) { + if (positionY <= this.state.initialPosition && allowStayMiddle) { endPosition = initialPosition; } else { endPosition = containerHeight; diff --git a/app/screens/channel_info/channel_info.js b/app/screens/channel_info/channel_info.js index 76ba03382..e94933882 100644 --- a/app/screens/channel_info/channel_info.js +++ b/app/screens/channel_info/channel_info.js @@ -11,14 +11,15 @@ import { View, } from 'react-native'; +import {General} from 'mattermost-redux/constants'; +import EventEmitter from 'mattermost-redux/utils/event_emitter'; + import StatusBar from 'app/components/status_bar'; import {preventDoubleTap} from 'app/utils/tap'; import {alertErrorWithFallback} from 'app/utils/general'; import {changeOpacity, makeStyleSheetFromTheme, setNavigatorStyles} from 'app/utils/theme'; import {t} from 'app/utils/i18n'; - -import {General} from 'mattermost-redux/constants'; -import EventEmitter from 'mattermost-redux/utils/event_emitter'; +import pinIcon from 'assets/images/channel_info/pin.png'; import ChannelInfoHeader from './channel_info_header'; import ChannelInfoRow from './channel_info_row'; @@ -141,6 +142,29 @@ export default class ChannelInfo extends PureComponent { }); }); + goToPinnedPosts = preventDoubleTap(() => { + const {formatMessage} = this.context.intl; + const {currentChannel, navigator, theme} = this.props; + const id = t('channel_header.pinnedPosts'); + const defaultMessage = 'Pinned Posts'; + + navigator.push({ + backButtonTitle: '', + screen: 'PinnedPosts', + title: formatMessage({id, defaultMessage}), + animated: true, + navigatorStyle: { + navBarTextColor: theme.sidebarHeaderTextColor, + navBarBackgroundColor: theme.sidebarHeaderBg, + navBarButtonColor: theme.sidebarHeaderTextColor, + screenBackgroundColor: theme.centerChannelBg, + }, + passProps: { + currentChannelId: currentChannel.id, + }, + }); + }); + handleChannelEdit = preventDoubleTap(() => { const {intl} = this.context; const {navigator, theme} = this.props; @@ -363,41 +387,50 @@ export default class ChannelInfo extends PureComponent { ); } - return ( - - - - { + return ( + + + + + + + { - /** - true} - defaultMessage='Notification Preferences' - icon='bell-o' - textId='channel_header.notificationPreferences' - theme={theme} - /> - - **/ - } - {this.renderViewOrManageMembersRow() && - + /** + true} + defaultMessage='Notification Preferences' + icon='bell-o' + textId='channel_header.notificationPreferences' + theme={theme} + /> + + **/ + } + {this.renderViewOrManageMembersRow() && + - - } - {canManageUsers && - + + } + {canManageUsers && + - - } - {canEditChannel && ( - - - - - )} - - ); + + } + {canEditChannel && ( + + + + + )} + + + ); }; render() { diff --git a/app/screens/channel_info/channel_info_row.js b/app/screens/channel_info/channel_info_row.js index 10413be06..a8c59fc23 100644 --- a/app/screens/channel_info/channel_info_row.js +++ b/app/screens/channel_info/channel_info_row.js @@ -46,7 +46,7 @@ function channelInfoRow(props) { iconElement = ( ); } diff --git a/app/screens/index.js b/app/screens/index.js index 6bee81ef7..a6b3986bc 100644 --- a/app/screens/index.js +++ b/app/screens/index.js @@ -46,6 +46,7 @@ export function registerScreens(store, Provider) { Navigation.registerComponent('NotificationSettingsMobile', () => wrapWithContextProvider(require('app/screens/settings/notification_settings_mobile').default), store, Provider); Navigation.registerComponent('OptionsModal', () => wrapWithContextProvider(require('app/screens/options_modal').default), store, Provider); Navigation.registerComponent('Permalink', () => wrapWithContextProvider(require('app/screens/permalink').default), store, Provider); + Navigation.registerComponent('PinnedPosts', () => wrapWithContextProvider(require('app/screens/pinned_posts').default), store, Provider); Navigation.registerComponent('PostOptions', () => wrapWithContextProvider(require('app/screens/post_options').default), store, Provider); Navigation.registerComponent('ReactionList', () => wrapWithContextProvider(require('app/screens/reaction_list').default), store, Provider); Navigation.registerComponent('RecentMentions', () => wrapWithContextProvider(require('app/screens/recent_mentions').default), store, Provider); diff --git a/app/screens/pinned_posts/index.js b/app/screens/pinned_posts/index.js new file mode 100644 index 000000000..bfc5df20d --- /dev/null +++ b/app/screens/pinned_posts/index.js @@ -0,0 +1,51 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {bindActionCreators} from 'redux'; +import {connect} from 'react-redux'; + +import {selectFocusedPostId, selectPost} from 'mattermost-redux/actions/posts'; +import {clearSearch, getPinnedPosts} from 'mattermost-redux/actions/search'; +import {RequestStatus} from 'mattermost-redux/constants'; +import {getTheme} from 'mattermost-redux/selectors/entities/preferences'; + +import {loadChannelsByTeamName, loadThreadIfNecessary} from 'app/actions/views/channel'; +import {showSearchModal} from 'app/actions/views/search'; +import {makePreparePostIdsForSearchPosts} from 'app/selectors/post_list'; + +import PinnedPosts from './pinned_posts'; + +function makeMapStateToProps() { + const preparePostIds = makePreparePostIdsForSearchPosts(); + return (state, ownProps) => { + const {pinned} = state.entities.search; + const channelPinnedPosts = pinned[ownProps.currentChannelId] || []; + const postIds = preparePostIds(state, channelPinnedPosts); + const {pinnedPosts: pinnedPostsRequest} = state.requests.search; + const isLoading = pinnedPostsRequest.status === RequestStatus.STARTED || + pinnedPostsRequest.status === RequestStatus.NOT_STARTED; + + return { + postIds, + isLoading, + didFail: pinnedPostsRequest.status === RequestStatus.FAILURE, + theme: getTheme(state), + }; + }; +} + +function mapDispatchToProps(dispatch) { + return { + actions: bindActionCreators({ + clearSearch, + loadChannelsByTeamName, + loadThreadIfNecessary, + getPinnedPosts, + selectFocusedPostId, + selectPost, + showSearchModal, + }, dispatch), + }; +} + +export default connect(makeMapStateToProps, mapDispatchToProps)(PinnedPosts); diff --git a/app/screens/pinned_posts/pinned_posts.js b/app/screens/pinned_posts/pinned_posts.js new file mode 100644 index 000000000..75b5768ff --- /dev/null +++ b/app/screens/pinned_posts/pinned_posts.js @@ -0,0 +1,288 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import React, {PureComponent} from 'react'; +import PropTypes from 'prop-types'; +import {intlShape} from 'react-intl'; +import { + Keyboard, + FlatList, + StyleSheet, + SafeAreaView, + View, +} from 'react-native'; + +import ChannelLoader from 'app/components/channel_loader'; +import DateHeader from 'app/components/post_list/date_header'; +import {isDateLine} from 'app/components/post_list/date_header/utils'; +import FailedNetworkAction from 'app/components/failed_network_action'; +import NoResults from 'app/components/no_results'; +import PostSeparator from 'app/components/post_separator'; +import StatusBar from 'app/components/status_bar'; +import mattermostManaged from 'app/mattermost_managed'; +import SearchResultPost from 'app/screens/search/search_result_post'; +import {changeOpacity} from 'app/utils/theme'; +import noResultsImage from 'assets/images/no_results/pin.png'; + +export default class PinnedPosts extends PureComponent { + static propTypes = { + actions: PropTypes.shape({ + clearSearch: PropTypes.func.isRequired, + loadChannelsByTeamName: PropTypes.func.isRequired, + loadThreadIfNecessary: PropTypes.func.isRequired, + getPinnedPosts: PropTypes.func.isRequired, + selectFocusedPostId: PropTypes.func.isRequired, + selectPost: PropTypes.func.isRequired, + showSearchModal: PropTypes.func.isRequired, + }).isRequired, + currentChannelId: PropTypes.string.isRequired, + didFail: PropTypes.bool, + isLoading: PropTypes.bool, + navigator: PropTypes.object, + postIds: PropTypes.array, + theme: PropTypes.object.isRequired, + }; + + static defaultProps = { + postIds: [], + }; + + static contextTypes = { + intl: intlShape.isRequired, + }; + + constructor(props) { + super(props); + + props.navigator.setOnNavigatorEvent(this.onNavigatorEvent); + + this.state = { + managedConfig: {}, + }; + } + + componentWillMount() { + this.listenerId = mattermostManaged.addEventListener('change', this.setManagedConfig); + } + + componentDidMount() { + const {actions, currentChannelId} = this.props; + this.setManagedConfig(); + actions.clearSearch(); + actions.getPinnedPosts(currentChannelId); + } + + componentWillUnmount() { + mattermostManaged.removeEventListener(this.listenerId); + } + + goToThread = (post) => { + const {actions, navigator, theme} = this.props; + const channelId = post.channel_id; + const rootId = (post.root_id || post.id); + + Keyboard.dismiss(); + actions.loadThreadIfNecessary(rootId, channelId); + actions.selectPost(rootId); + + const options = { + screen: 'Thread', + animated: true, + backButtonTitle: '', + navigatorStyle: { + navBarTextColor: theme.sidebarHeaderTextColor, + navBarBackgroundColor: theme.sidebarHeaderBg, + navBarButtonColor: theme.sidebarHeaderTextColor, + screenBackgroundColor: theme.centerChannelBg, + }, + passProps: { + channelId, + rootId, + }, + }; + + navigator.push(options); + }; + + handleClosePermalink = () => { + const {actions} = this.props; + actions.selectFocusedPostId(''); + this.showingPermalink = false; + }; + + handlePermalinkPress = (postId, teamName) => { + this.props.actions.loadChannelsByTeamName(teamName); + this.showPermalinkView(postId, true); + }; + + handleHashtagPress = async (hashtag) => { + const {actions, navigator} = this.props; + + await navigator.dismissModal(); + + actions.showSearchModal(navigator, '#' + hashtag); + }; + + keyExtractor = (item) => item; + + onNavigatorEvent = (event) => { + if (event.type === 'NavBarButtonPress') { + if (event.id === 'close-settings') { + this.props.navigator.dismissModal({ + animationType: 'slide-down', + }); + } + } + }; + + previewPost = (post) => { + Keyboard.dismiss(); + + this.showPermalinkView(post.id, false); + }; + + renderEmpty = () => { + const {formatMessage} = this.context.intl; + const {theme} = this.props; + + return ( + + ); + }; + + renderPost = ({item, index}) => { + const {postIds, theme} = this.props; + const {managedConfig} = this.state; + if (isDateLine(item)) { + return ( + + ); + } + + let separator; + const nextPost = postIds[index + 1]; + if (nextPost && !isDateLine(nextPost)) { + separator = ; + } + + return ( + + + {separator} + + ); + }; + + setManagedConfig = async (config) => { + let nextConfig = config; + if (!nextConfig) { + nextConfig = await mattermostManaged.getLocalConfig(); + } + + this.setState({ + managedConfig: nextConfig, + }); + }; + + showPermalinkView = (postId, isPermalink) => { + const {actions, navigator} = this.props; + + actions.selectFocusedPostId(postId); + + if (!this.showingPermalink) { + const options = { + screen: 'Permalink', + animationType: 'none', + backButtonTitle: '', + overrideBackPress: true, + navigatorStyle: { + navBarHidden: true, + screenBackgroundColor: changeOpacity('#000', 0.2), + modalPresentationStyle: 'overCurrentContext', + }, + passProps: { + isPermalink, + onClose: this.handleClosePermalink, + }, + }; + + this.showingPermalink = true; + navigator.showModal(options); + } + }; + + retry = () => { + const {actions, currentChannelId} = this.props; + actions.getPinnedPosts(currentChannelId); + }; + + render() { + const {didFail, isLoading, postIds, theme} = this.props; + + let component; + if (didFail) { + component = ( + + ); + } else if (isLoading) { + component = ( + + ); + } else if (postIds.length) { + component = ( + + ); + } else { + component = this.renderEmpty(); + } + + return ( + + + + {component} + + + ); + } +} + +const style = StyleSheet.create({ + container: { + flex: 1, + }, +}); diff --git a/app/screens/post_options/index.js b/app/screens/post_options/index.js index ac7774d36..a773f1afe 100644 --- a/app/screens/post_options/index.js +++ b/app/screens/post_options/index.js @@ -4,7 +4,14 @@ import {bindActionCreators} from 'redux'; import {connect} from 'react-redux'; -import {deletePost, flagPost, unflagPost, removePost} from 'mattermost-redux/actions/posts'; +import { + deletePost, + flagPost, + pinPost, + unflagPost, + unpinPost, + removePost, +} from 'mattermost-redux/actions/posts'; import {General, Permissions} from 'mattermost-redux/constants'; import {getChannel, getCurrentChannelId} from 'mattermost-redux/selectors/entities/channels'; import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users'; @@ -72,8 +79,10 @@ function mapDispatchToProps(dispatch) { addReaction, deletePost, flagPost, + pinPost, removePost, unflagPost, + unpinPost, }, dispatch), }; } diff --git a/app/screens/post_options/post_option.js b/app/screens/post_options/post_option.js index 38f80c25f..2dbeb0dda 100644 --- a/app/screens/post_options/post_option.js +++ b/app/screens/post_options/post_option.js @@ -16,6 +16,7 @@ import edit from 'assets/images/post_menu/edit.png'; import emoji from 'assets/images/post_menu/emoji.png'; import flag from 'assets/images/post_menu/flag.png'; import link from 'assets/images/post_menu/link.png'; +import pin from 'assets/images/post_menu/pin.png'; import trash from 'assets/images/post_menu/trash.png'; const icons = { @@ -24,6 +25,7 @@ const icons = { emoji, flag, link, + pin, trash, }; diff --git a/app/screens/post_options/post_options.js b/app/screens/post_options/post_options.js index 4c6526b89..7d4bf2352 100644 --- a/app/screens/post_options/post_options.js +++ b/app/screens/post_options/post_options.js @@ -21,8 +21,10 @@ export default class PostOptions extends PureComponent { addReaction: PropTypes.func.isRequired, deletePost: PropTypes.func.isRequired, flagPost: PropTypes.func.isRequired, + pinPost: PropTypes.func.isRequired, removePost: PropTypes.func.isRequired, unflagPost: PropTypes.func.isRequired, + unpinPost: PropTypes.func.isRequired, }).isRequired, additionalOption: PropTypes.object, canAddReaction: PropTypes.bool, @@ -159,7 +161,7 @@ export default class PostOptions extends PureComponent { ); @@ -169,16 +171,46 @@ export default class PostOptions extends PureComponent { ); }; + getPinOption = () => { + const {formatMessage} = this.context.intl; + const {channelIsReadOnly, post} = this.props; + + if (channelIsReadOnly) { + return null; + } + + if (post.is_pinned) { + return ( + + ); + } + + return ( + + ); + }; + getMyPostOptions = () => { const actions = [ this.getEditOption(), this.getFlagOption(), + this.getPinOption(), this.getAddReactionOption(), this.getCopyPermalink(), this.getCopyText(), @@ -192,6 +224,7 @@ export default class PostOptions extends PureComponent { const actions = [ this.getFlagOption(), this.getAddReactionOption(), + this.getPinOption(), this.getCopyPermalink(), this.getCopyText(), this.getEditOption(), @@ -265,6 +298,13 @@ export default class PostOptions extends PureComponent { this.closeWithAnimation(); }; + handlePinPost = () => { + const {actions, post} = this.props; + + actions.pinPost(post.id); + this.closeWithAnimation(); + }; + handlePostDelete = () => { const {formatMessage} = this.context.intl; const {actions, isMyPost, post} = this.props; @@ -325,6 +365,13 @@ export default class PostOptions extends PureComponent { this.closeWithAnimation(); }; + handleUnpinPost = () => { + const {actions, post} = this.props; + + actions.unpinPost(post.id); + this.closeWithAnimation(); + }; + refSlideUpPanel = (r) => { this.slideUpPanel = r; }; @@ -338,6 +385,7 @@ export default class PostOptions extends PureComponent { return ( -RI1;(VL#D7aWav^L&U?kc)u4t*|Mg_*}^G_UzS*#2x zTisPi^De|>Ji3&?gT5$|R5B;$RJ$!#l0LOXp*`;c3stx2-fI*Y@@+r#=F1MFP{wex aFZKi2_BuhaF6LeU0000v0y^JVYO%%)9_ zySRf?4cv1lnYmIfrN5yOR%b5buCgCeG9-S&D#&X14VkHx*>q%v-e95_%+!i(nerQw zhs~a;2CjwbXzA$BgnBA^LKa(Ss86PrTW3pL+cL>9(Bv-z6K?bRWolVq?7!T$L$wrS zrVeI8Ft*d7@p@Pq9CUf#0kJaBWba`bq>3F3n5lwPNlE%UQhd&vq?YT%2x7au{2yGG zM97bv(uK@Q&|mx-#z55AO`_^#YH`$}ba~wvybKbphLI3A_E2D^7R4!UMRG6D6POQ0 za1A8(9EL)sv6n*We20apL?$s%0-E#{XsN0w$P@a8K+@QUfSFnlCic)v@Eg$T(dR@t z9EF`Q5L|?Qf&HNQzQH_b5;mwOqn%aD+I0*^@CH2cc+AxvfK27X-)K+k3@G6p=qGd; zd5CEzHv?f;{Yp(jJMSq_vVN{9;35Vi@(iSV5k`SlsGnincZPpLrbu!sdUFeI&poLJn(e1db_7fZf;Dbj)P2ngy|3``jC7VRDTn4A#uo* zz36qcCqz{2N!~4xnJVZFPD0DH8YFxHLSH)i{n3M)>!6zZ&=;J<2vp2WC2cdK?O{Bm zij54&jP{xVsgmOM>!-R660QKhnX}w~hFK66w!y9o8_cwCJ%uu(owSoDq9;=Z>K9*N zHU#4|9U6D`ABQ$EO(s$MJkT*>W@>4l^v0E!5B|8yjcRB3JCn3+Q`C}IXhK`QHMGY; zYd8#=_$$l-3w!Ted=canpsGne!DKk%-K$UM%vi9{X?+OH)Lhy!+6ccuJAR_jck)xv z1lmJ%Y^beMcoT%3{xWqCEN`vXe#2HZ--IUI{h8RRjLulaVc~hSiX7@C alKKPd9O`#s@AC8j0000{AXoW1rwXX@(G$`QhS3mVdfjT`Mr+~01+POc9Oxn~VA5!?alLE)tl2H}o{ z>vq}-PcDm?2yO$NL1D!by9JAI*L2+i3QNusI|-4G9l6a#CFhC3qi(h%w+XwGI*KG& z#FYOWU?0nk*o7jOM@;1AIdU6{MDA6Syw;eP`83!&w;sErAJpFLp(Jz-gv5{LB)Gtg z(b%=1-ICLtx^5CajjmHjo_tD2_yKfX4cZ;K*GcMzNmP$uA5cm@XMiQQ3cKBq8%Z)Z zO@hbJp8@5{LIzrLE3rG2J2}?4u{gG;ix%vj6$D$v97}FF_I)EaiQtx$3WZ)-DwQvo zXvryfL zt#L$=_{zok2D@r@>DO2DjZFYoYvr7a(5LPCE9QL-O>L0 z=Ne5HGH{+YL0_JWP8;w6ZM{j2hOwiVa7<*hUrAVp2 z#jmlDtH^213=9oir+`n49b2s@fVPrXOrYuL`;6^Xk-7v<`3`IAxjqQ&FC=m)MAg;r zkMGtpur}obz>?FJ5K~mpHUb-SMvQyBGhCY}H6uUOMZ z1K--0mbn=7v7?K&g1x|!d#^@ORmA)mP;b!5MC5$=Ri&y{{L7?{N6F<4BHFul99tE% zQu&OD)}M|^dD~E>F-p!G&WP(EBqCD1O3un3;J*N`V5rtwQW-(C8z7K6hVDt}q)@KB zz`)>3*LA?3{mS)K@wmavw4{E=_N4R~;4mn+JP%`RAa(@ZAINEnD=PJ;K}bTRo(5JP z!w{)3W{&O5(cd!TRS}cRY$Aq)|3=Ru)ojLuv06WE$xJ%KSm{aSIilHOG0H{PoysB= z5^igQwfmOtUQ*dlw2jh75ubWJp=H|T-=n_u-#6)Tgup)BnK}cLz ze?RsUp=)C2t>YWoKy*^~zm zNMD;mLLzy>*lJh5l2q|%OSIvx0l}B9Iv4geZRgOVixN2sY$9G)!T3LGLOxeHpDIMb zulz-q$wUYN{q-V!<3J^kjmWLY@x6uisGx7$761SM literal 0 HcmV?d00001 diff --git a/assets/base/images/no_results/pin.png b/assets/base/images/no_results/pin.png new file mode 100644 index 0000000000000000000000000000000000000000..20f70f38debbed48ed015eca49fdcdf5bc8a3ad6 GIT binary patch literal 907 zcmV;619bd}P)a->J>#6SkjUxx)OxinYtLS%*?T|r>pQ*Q`#rP1^?iFa zseZ}o4(Z;3%A!#TPJY8rs4W^-7?}t%X6Duwi#aovjfarU7^jn zjmgVam$`;Qn)4cymn|-H8w+7>5Q`pnOg#1&*yQwUYgNyihOkmAXyFgA2)xqM(qT}{ zX76BMw1<=Q3~0gEFc-3E0|RDG?_Zy3G?_a9ej%4Z*C|?H3A+qGW3zSS( zL>WZU2qs+w<>`iL5L;u&&DRTP z4?it%82W>xm-#&C7oiILw3klJYw{b6+h?E3tt2v2I`($LwD4P)2VNbaL*whc1998S zoWy#!?9S04unV$>`Y9*oJ>^1h>og4+Tkh_RTOUdS-ONh7%}KV3QYTA&8A!uGI05SW z2#djFmvgqQwzyE`exkKl7gR!<(^g+6xG&li?G|VJ6J-rZ+G54)l1@bWqWBI38+Zc>ddCFuYyVCZ}75R^i1(p<%9o_$+odYcettlmj% zrmoVSA0mOw9dPB);fO7L+UOr{GW7=Pz;D~=&Z$Ns^yC-hj0_OaLX5WUAe01%&r3)J htF%LPURDdZ$R8C{?6Q!&LF)hj002ovPDHLkV1n{Yp;-U` literal 0 HcmV?d00001 diff --git a/assets/base/images/no_results/pin@2x.png b/assets/base/images/no_results/pin@2x.png new file mode 100644 index 0000000000000000000000000000000000000000..de04fd29767f10e8666f6da497748e23e09b20a4 GIT binary patch literal 1959 zcmV;Y2Uz%tP)2toO-Cfh!mJ7FM01=K zvsle+O#aY}#F9fK$FX9KuwU7YkYh0=fkV6LBK#+=xDPt&Sp0q-_W1)ipbT!51P-r2Pg41p3vj$`Yox z%FSi&DbfbkEkkDDmatW{Uk}_?!;!WF-G!!aT~kZB^(uu36DQ+Ah_p+Z2>WNU*BoZ$auk08H=pVG2N(wGPEKaTJ`o|pM9NqYB5i~k z>qQP=pznbsgo%^UV5hNH{W9>sm-dWMS2{jfDld&0`AFJBq>YN{SJTahk^N`DlJz0F zb_qtN&(M;l&vi_KzBOmkiHsY7C2Ki4-Hj!c;TUS-_x*^paY-H2f}QTZz5)IqRelAu z0>#RubO@1lWwDO(Xy2Knz6X}9=h5w5M#2}4hlDlYXkxuTOqHDo;-0 zWsf9A1#H^T*5O|q;+3USUB{j7z!!a%qrtPjvcl!Fh_@KDHgRvGe!tNbRn_KEH<{DH zT<{!t7pw(af&DAR)ivR3)yjkPBZ<&zeS4|N=|_i6AdZBj?IQT>Qj^K3Utj!&{_s9soLQC5(Y zwj4`9TQ9*oDeKfR)6+$s*_L6X$$2l|nDXP`Y2ZlE@upTppI@Psa3k$LVH(#A&;m?T zB~Grxz`mw>qiXTj=04;dnyc*iqUtm0pGa=T$~w`xOg90wB4?0jXpXYuimNAnQM6j_ z0=`3iuERjyMGH+DWNGYrd735NxK6-!p0U?%eJpqevD3upC{Rl%pPxzYtloQ;w6FyAF8%9N5418|YeR z5cRDOXk03CQ+MMTu+^a#+UTq&}SPW~T9zG^~r%kehwlUMm=&=Q11 ztJ+2&yM@5s=Aw!fyHQU2laEcHRD4K5#r{|g^o$_Gwlu2B>{7w;x^+X>!5c>lc*->+z z?{)wczu)VAV_wTT2Kh;=JUJrSUg$%Yc=qj-k7Z!r2tQ73r-3J3LtdYn%)bIYVcM*Q ze%tJWev7eHKb`TS_H8R@YYU=8*ED|Yd!r2VqSsZ4pRlf{brtN3cJdQ(hzMXx$ecP&*$C*Tx z^l+fhZw?53ezN%*3t(1-o_gsVSP@p~DF0WnlXK1lOU0r#C@Gg1xTp!T6wwGu-y2O-4@X~;9 zt<0wxz5ZCalfGo+h=_Zx!#2t17dOb37-;uzCP;d` z!8BGBTjxw0%dfu}>!rd5(_#`<@>=GP$|x_*Sqp(rl3#u_qqrrxJ%(O-bNmGT@><#k zmE^g&PAxEB0Q}glJfb*P*;>CAk)%kR0n+^Z;zrUOgNK15O3yDqC37HLv}m{PC+EtG z&>tDyE%TQCM9_-;80eUJw1ffLVYdM|lJsl<13{An7?_XFk+qGoCVnmRO%h<>RCI19 tttI`4fK8LiV_-VvPl0YVo1_2(e*-ijj{fA#hN=Jn002ovPDHLkV1oNE*0ulu literal 0 HcmV?d00001 diff --git a/assets/base/images/no_results/pin@3x.png b/assets/base/images/no_results/pin@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..4212d2dd16f8ffc9d5dc687e9b51e7b00eb2de9e GIT binary patch literal 2650 zcmeHJ=QkUQ15Jr7YLC)3HA{_%`K%I=VMooPMoB}B8bwHr7!7K5kkX-M``S_wLZ2!X zRK?R!ql%QO(dL=8MSp&O#yjWTbMC$8+)wx1PdD4y5eX7JAqW5fKxh=)<;bIt7{n=#tQqv}hsE{+!f$GEtG zJiL7T0)j%qAd%yuV&W2#QqnTAawp^!PM$gqKBK6ltfH!RR$b$qrk1u2L>H>3Z(wL- z{D+CDnYqPzODmW)+y;R}p>6H#9UPr77hK)kJv_ZGdi(fZ^79Y492AVbf(r=^3y+Ar z78M;6dp#~b0iT$3BRM5CjhK;{bu&BXR$hKV;hnqpic84%%N{&@^tk*vyz_#C0rq{E?o?>J|!~L4Bb@b~hgX>expNYF>dbBrcplXrVi=>$Qh2A(N6UaB*I&kvm3H}N*o6a$bb@TB zFg`*Bi_o=>4(SNX#b(VFZ=ad3Y-JSs2tvO2`De{d1F^kkSX4xa4rD>kIyyJ)b8D*H zZM$VFM%{P-Ts5cAXGnt1PQE@)y0AhjnuIZKomQQt?zaK^-W^DL0bm4`Yc2JuX`TG0g%>Vnwhd{?C=zZP%G^u39cF(koo{2M4AdQnr8s2wz0%(w zyJ%}*%Jg3mmltPcSq_(AqY{$&Agot%YbT9QCCwe?;XBw#{RsN@SA)3z$aZp^zjmWQ z6rbo9KT4p!jC3q3T3$3J3Ukz@n(@$64kSHqTBF#04vq?VUXqcy)aRQ4SXK44XHc+v(%E19;SJ(dlGKKhG|#2F zakUmX0tH3-R!`hkk&(L8SlV&*&BbZ{m4&Mvv{%lGntKRV(Y?O`#LJlc?;0r0v1UdmU_ zlWJ!C=4k3cfN4e3;S1>l+Wi8XO>dRuGgHiCg~{utjI8xJ|GhodU`mnIDcZqGXL=kO zDUxfY)g+U1Jlso6h8^IP#I-w}d1bj6V>}TTq-?S99e$&^?mRAx)&J(9%i?dJuFvHs zU+vu9%FgqNwEXR{^^4Ad3RzRKR1c1EY87zVt(0>qMaqeO>4)M-lUa-10e5xX>(7a4 zdP+D_(hK&AS!2wa;H?MpXvay=;Eta6kYSK2zO1ZZSB~9}xFa5Otp|z@>4`dKmKEm4u6dw@zq-6UTynUZD@BW zZC3wWGc>&Ca}#avFH7B>(S)Z_{k-+B)q%N|8!8FEs|iz4@|_E;XK*{;3nY(6voy*n zv*o?p(`<|G6`L8&;53f;`Sq5};f%iyZC{KLH*cimSIp=+Juiop3iOp4rY;M=Q-g5| zdAdd{KVeG3`_sG(GJH)$e}?2XBf`Jt7hLahBg>-fG&}Z#NC~a5$V%eNdA&>~*e$X$ z`XxsIJWa+;U4x32VVUAlcV9{??gMj+G0uO2YLra~-1Mv8GCW3${QF`BUOPF<+>D={ zno$h{n*Rb=*P;%#V*Pk+H}LbZvo`?PWB7TgYO~!GoC=SNb@%bY{C=%v&iBZziS7`=X!*=G~lY4 zq8w*)B;Wl++4h_fkSfNi<@Ht((*op63@I#^E$1x6x$0Ozxf?t;8+H)aNJKL6QNlE& zcFA)}(xo4uIjfM7uLK>@=^l_m=lZ5M8rM#?ARO}TStg77K!GroEI*g`MTPufCrNy} z#43;A#Gt*&tZ6Rp#&@e5xpX>?s&ZA~A82Vt0e&<)6Flf$!zEUVo1GhZaw5EsyefER zT){|4^G+yF8Y=wa$A!&h4y-TKZl!qCMQ)$^! zq3v#;=7f_+uN9+o505!vvOaL#+OC=Gh8vUxk%tLJ`Is0)Z|6NozL(y6t-Pf;HYtGH uZ+WTUb(HWIOYOC*XM3sl-0CTp`}R-En~~*Ki1g6^zX7z3BfJjgPy8QWVy^iB literal 0 HcmV?d00001