From 661bea1d5b26cb14cfb0409d1ff4b7d780d8142a Mon Sep 17 00:00:00 2001 From: Michel Weststrate Date: Thu, 12 Nov 2020 04:13:16 -0800 Subject: [PATCH] Convert Navigation plugin to Sandy Summary: Converted the Navigation plugin to Sandy, and updated Locations bookmark accordingly. This is a prerequisite step of supporting the bookmarkswidgetin the new AppInspect tab. Updated LocationsButton accordingly, and overal simplified implementation a bit; locationsbutton now reuses the logic of the NavigationPlugin, rather than reimplemting it. This reduces code duplication and also makes sure the state between plugin and location button stays in sync. Made sure that search providers are derived and cached rather than stored, again simplifying logic That being said, the navigation plugin is buggy, but all these things failed before this diff as well: * No events happening when using iOS, despite the plugin being enabled. But these seems to be a long time know issue, looks like it was never implemented * Not sure if the parameterized bookmarks is working correctly * screenshots not always happening at the right time (but fixed a race condition where the wrong bookmark might get updated) * Locations button doesn't show up if the navigation plugin is supported but not enabled (will try to fix in next diff) Would be great if bnelo12 could do some exploratory testing to verify what ought to be working, but currently isn't. Reviewed By: cekkaewnumchai Differential Revision: D24860757 fbshipit-source-id: e4b56072de8c42af2ada0f5bb022cb9f8c04bb47 --- desktop/app/src/chrome/LocationsButton.tsx | 264 ++++++------- desktop/app/src/chrome/TitleBar.tsx | 36 +- desktop/flipper-plugin/src/state/atom.tsx | 4 +- desktop/plugins/navigation/index.tsx | 356 +++++++++--------- desktop/plugins/navigation/package.json | 3 + desktop/plugins/navigation/types.tsx | 19 +- desktop/plugins/navigation/util/indexedDB.tsx | 2 +- desktop/static/icons.json | 6 + 8 files changed, 325 insertions(+), 365 deletions(-) diff --git a/desktop/app/src/chrome/LocationsButton.tsx b/desktop/app/src/chrome/LocationsButton.tsx index 34f828160..b25f738d7 100644 --- a/desktop/app/src/chrome/LocationsButton.tsx +++ b/desktop/app/src/chrome/LocationsButton.tsx @@ -7,43 +7,19 @@ * @format */ -import {Button, styled} from '../ui'; -import {connect} from 'react-redux'; -import React, {Component} from 'react'; -import {State as Store} from '../reducers'; -// TODO T71355623 -// eslint-disable-next-line flipper/no-relative-imports-across-packages -import { - readBookmarksFromDB, - writeBookmarkToDB, -} from '../../../plugins/navigation/util/indexedDB'; -// TODO T71355623 -// eslint-disable-next-line flipper/no-relative-imports-across-packages -import {PersistedState as NavPluginState} from '../../../plugins/navigation/types'; -import BaseDevice from '../devices/BaseDevice'; -import {State as PluginState} from '../reducers/pluginStates'; +import React, {useCallback, useEffect} from 'react'; import {platform} from 'os'; -import {getPluginKey} from '../utils/pluginUtils'; +import {useValue} from 'flipper-plugin'; +import {Button, styled} from '../ui'; +import {useStore} from '../utils/useStore'; +import {useMemoize} from '../utils/useMemoize'; +import {State} from '../reducers'; -type State = { - bookmarks: Array; - hasRetrievedBookmarks: boolean; - retreivingBookmarks: boolean; -}; - -type OwnProps = {}; - -type StateFromProps = { - currentURI: string | undefined; - selectedDevice: BaseDevice | null | undefined; -}; - -type DispatchFromProps = {}; - -type Bookmark = { - uri: string; - commonName: string | null; -}; +// TODO T71355623 +// eslint-disable-next-line flipper/no-relative-imports-across-packages +import type {NavigationPlugin} from '../../../plugins/navigation/index'; +// eslint-disable-next-line flipper/no-relative-imports-across-packages +import type {Bookmark} from '../../../plugins/navigation/types'; const DropdownButton = styled(Button)({ fontSize: 11, @@ -57,127 +33,105 @@ const shortenText = (text: string, MAX_CHARACTERS = 30): string => { } }; -type Props = OwnProps & StateFromProps & DispatchFromProps; -class LocationsButton extends Component { - state: State = { - bookmarks: [], - hasRetrievedBookmarks: false, - retreivingBookmarks: false, - }; +const NAVIGATION_PLUGIN_ID = 'Navigation'; - componentDidMount() { - document.addEventListener('keydown', this.keyDown); - this.updateBookmarks(); - } - - componentWillUnmount() { - document.removeEventListener('keydown', this.keyDown); - } - - goToLocation = (location: string) => { - const {selectedDevice} = this.props; - if (selectedDevice != null) { - selectedDevice.navigateToLocation(location); - } - }; - - keyDown = (e: KeyboardEvent) => { - if ( - ((platform() === 'darwin' && e.metaKey) || - (platform() !== 'darwin' && e.ctrlKey)) && - /^\d$/.test(e.key) && - this.state.bookmarks.length >= parseInt(e.key, 10) - ) { - this.goToLocation(this.state.bookmarks[parseInt(e.key, 10) - 1].uri); - } - }; - - updateBookmarks = () => { - readBookmarksFromDB().then((bookmarksMap) => { - const bookmarks: Array = []; - bookmarksMap.forEach((bookmark: Bookmark) => { - bookmarks.push(bookmark); - }); - this.setState({bookmarks}); - }); - }; - - render() { - const {currentURI} = this.props; - const {bookmarks} = this.state; - - const dropdown: any[] = [ - { - label: 'Bookmarks', - enabled: false, - }, - ...bookmarks.map((bookmark, i) => { - return { - click: () => { - this.goToLocation(bookmark.uri); - }, - accelerator: i < 9 ? `CmdOrCtrl+${i + 1}` : undefined, - label: shortenText( - (bookmark.commonName ? bookmark.commonName + ' - ' : '') + - bookmark.uri, - 100, - ), - }; - }), - ]; - - if (currentURI) { - dropdown.push( - {type: 'separator'}, - { - label: 'Bookmark Current Location', - click: async () => { - await writeBookmarkToDB({ - uri: currentURI, - commonName: null, - }); - this.updateBookmarks(); - }, - }, - ); - } - - return ( - - {(currentURI && shortenText(currentURI)) || '(none)'} - - ); - } +export function LocationsButton() { + const navPlugin = useStore(navPluginStateSelector); + return navPlugin ? ( + + ) : ( + (none) + ); } -const mapStateFromPluginStatesToProps = ( - pluginStates: PluginState, - selectedDevice: BaseDevice | null, - selectedApp: string | null, -) => { - const pluginKey = getPluginKey(selectedApp, selectedDevice, 'Navigation'); - let currentURI: string | undefined; - if (pluginKey) { - const navPluginState = pluginStates[pluginKey] as - | NavPluginState - | undefined; - currentURI = navPluginState && navPluginState.currentURI; - } - return { - currentURI, - }; -}; +function ActiveLocationsButton({navPlugin}: {navPlugin: NavigationPlugin}) { + const currentURI = useValue(navPlugin.currentURI); + const bookmarks = useValue(navPlugin.bookmarks); -export default connect( - ({connections: {selectedDevice, selectedApp}, pluginStates}) => ({ - selectedDevice, - ...mapStateFromPluginStatesToProps( - pluginStates, - selectedDevice, - selectedApp, - ), - }), -)(LocationsButton); + const keyDown = useCallback( + (e: KeyboardEvent) => { + if ( + ((platform() === 'darwin' && e.metaKey) || + (platform() !== 'darwin' && e.ctrlKey)) && + /^\d$/.test(e.key) && + bookmarks.size >= parseInt(e.key, 10) + ) { + navPlugin.navigateTo( + Array.from(bookmarks.values())[parseInt(e.key, 10) - 1].uri, + ); + } + }, + [bookmarks, navPlugin], + ); + + useEffect(() => { + document.addEventListener('keydown', keyDown); + return () => { + document.removeEventListener('keydown', keyDown); + }; + }, [keyDown]); + + const dropdown = useMemoize(computeBookmarkDropdown, [ + navPlugin, + bookmarks, + currentURI, + ]); + + return ( + + {(currentURI && shortenText(currentURI)) || '(none)'} + + ); +} + +export function navPluginStateSelector(state: State) { + const {selectedApp, clients} = state.connections; + if (!selectedApp) return undefined; + const client = clients.find((client) => client.id === selectedApp); + if (!client) return undefined; + return client.sandyPluginStates.get(NAVIGATION_PLUGIN_ID)?.instanceApi as + | undefined + | NavigationPlugin; +} + +function computeBookmarkDropdown( + navPlugin: NavigationPlugin, + bookmarks: Map, + currentURI: string, +) { + const dropdown: Electron.MenuItemConstructorOptions[] = [ + { + label: 'Bookmarks', + enabled: false, + }, + ...Array.from(bookmarks.values()).map((bookmark, i) => { + return { + click: () => { + navPlugin.navigateTo(bookmark.uri); + }, + accelerator: i < 9 ? `CmdOrCtrl+${i + 1}` : undefined, + label: shortenText( + (bookmark.commonName ? bookmark.commonName + ' - ' : '') + + bookmark.uri, + 100, + ), + }; + }), + ]; + + if (currentURI) { + dropdown.push( + {type: 'separator'}, + { + label: 'Bookmark Current Location', + click: () => { + navPlugin.addBookmark({ + uri: currentURI, + commonName: null, + }); + }, + }, + ); + } + return dropdown; +} diff --git a/desktop/app/src/chrome/TitleBar.tsx b/desktop/app/src/chrome/TitleBar.tsx index 465049eff..631fbe4af 100644 --- a/desktop/app/src/chrome/TitleBar.tsx +++ b/desktop/app/src/chrome/TitleBar.tsx @@ -31,7 +31,7 @@ import { import {connect} from 'react-redux'; import RatingButton from './RatingButton'; import DevicesButton from './DevicesButton'; -import LocationsButton from './LocationsButton'; +import {LocationsButton} from './LocationsButton'; import ScreenCaptureButtons from './ScreenCaptureButtons'; import AutoUpdateVersion from './AutoUpdateVersion'; import UpdateIndicator from './UpdateIndicator'; @@ -45,7 +45,7 @@ import {reportUsage} from '../utils/metrics'; import FpsGraph from './FpsGraph'; import NetworkGraph from './NetworkGraph'; import MetroButton from './MetroButton'; -import {getPluginKey} from '../utils/pluginUtils'; +import {navPluginStateSelector} from './LocationsButton'; const AppTitleBar = styled(FlexRow)<{focused?: boolean}>(({focused}) => ({ userSelect: 'none', @@ -228,25 +228,19 @@ class TitleBar extends React.Component { } export default connect( - ({ - application: { - windowIsFocused, - leftSidebarVisible, - rightSidebarVisible, - rightSidebarAvailable, - downloadingImportData, - launcherMsg, - share, - }, - connections: {selectedDevice, selectedApp}, - pluginStates, - }) => { - const navigationPluginKey = getPluginKey( - selectedApp, - selectedDevice, - 'Navigation', - ); - const navPluginIsActive = !!pluginStates[navigationPluginKey]; + (state) => { + const { + application: { + windowIsFocused, + leftSidebarVisible, + rightSidebarVisible, + rightSidebarAvailable, + downloadingImportData, + launcherMsg, + share, + }, + } = state; + const navPluginIsActive = !!navPluginStateSelector(state); return { windowIsFocused, diff --git a/desktop/flipper-plugin/src/state/atom.tsx b/desktop/flipper-plugin/src/state/atom.tsx index c62b81809..ab510aaae 100644 --- a/desktop/flipper-plugin/src/state/atom.tsx +++ b/desktop/flipper-plugin/src/state/atom.tsx @@ -7,10 +7,12 @@ * @format */ -import {produce, Draft} from 'immer'; +import {produce, Draft, enableMapSet} from 'immer'; import {useState, useEffect} from 'react'; import {getCurrentPluginInstance} from '../plugin/PluginBase'; +enableMapSet(); + export type Atom = { get(): T; set(newValue: T): void; diff --git a/desktop/plugins/navigation/index.tsx b/desktop/plugins/navigation/index.tsx index 2fd0e1991..1e21e8359 100644 --- a/desktop/plugins/navigation/index.tsx +++ b/desktop/plugins/navigation/index.tsx @@ -8,7 +8,7 @@ * @flow strict-local */ -import {FlipperPlugin, FlexColumn, bufferToBlob} from 'flipper'; +import {bufferToBlob} from 'flipper'; import { BookmarksSidebar, SaveBookmarkDialog, @@ -17,217 +17,227 @@ import { RequiredParametersDialog, } from './components'; import { - removeBookmark, + removeBookmarkFromDB, readBookmarksFromDB, writeBookmarkToDB, } from './util/indexedDB'; import { appMatchPatternsToAutoCompleteProvider, bookmarksToAutoCompleteProvider, - DefaultProvider, } from './util/autoCompleteProvider'; import {getAppMatchPatterns} from './util/appMatchPatterns'; import {getRequiredParameters, filterOptionalParameters} from './util/uri'; import { - State, - PersistedState, Bookmark, NavigationEvent, AppMatchPattern, + URI, + RawNavigationEvent, } from './types'; -import React from 'react'; +import React, {useMemo} from 'react'; +import { + PluginClient, + createState, + useValue, + usePlugin, + Layout, +} from 'flipper-plugin'; -export default class extends FlipperPlugin { - static defaultPersistedState = { - navigationEvents: [], - bookmarks: new Map(), - currentURI: '', - bookmarksProvider: DefaultProvider(), - appMatchPatterns: [], - appMatchPatternsProvider: DefaultProvider(), - }; +export type State = { + shouldShowSaveBookmarkDialog: boolean; + shouldShowURIErrorDialog: boolean; + saveBookmarkURI: URI | null; + requiredParameters: Array; +}; - state = { - shouldShowSaveBookmarkDialog: false, - saveBookmarkURI: null as string | null, - shouldShowURIErrorDialog: false, - requiredParameters: [], - }; +type Events = { + nav_event: RawNavigationEvent; +}; - static persistedStateReducer = ( - persistedState: PersistedState, - method: string, - payload: any, - ) => { - switch (method) { - case 'nav_event': - const navigationEvent: NavigationEvent = { - uri: - payload.uri === undefined ? null : decodeURIComponent(payload.uri), - date: new Date(payload.date) || new Date(), - className: payload.class === undefined ? null : payload.class, - screenshot: null, - }; +type Methods = { + navigate_to(params: {url: string}): Promise; +}; - return { - ...persistedState, - currentURI: - navigationEvent.uri == null - ? persistedState.currentURI - : decodeURIComponent(navigationEvent.uri), - navigationEvents: [ - navigationEvent, - ...persistedState.navigationEvents, - ], - }; - default: - return persistedState; - } - }; +export type NavigationPlugin = ReturnType; - subscribeToNavigationEvents = () => { - this.client.subscribe('nav_event', () => - // Wait for view to render and then take a screenshot - setTimeout(async () => { - const device = await this.getDevice(); - const screenshot = await device.screenshot(); - const blobURL = URL.createObjectURL(bufferToBlob(screenshot)); - this.props.persistedState.navigationEvents[0].screenshot = blobURL; - this.props.setPersistedState({...this.props.persistedState}); - }, 1000), - ); - }; +export function plugin(client: PluginClient) { + const bookmarks = createState(new Map(), { + persist: 'bookmarks', + }); + const navigationEvents = createState([], { + persist: 'navigationEvents', + }); + const appMatchPatterns = createState([], { + persist: 'appMatchPatterns', + }); + const currentURI = createState(''); + const shouldShowURIErrorDialog = createState(false); + const requiredParameters = createState([]); + const shouldShowSaveBookmarkDialog = createState(false); + const saveBookmarkURI = createState(null); - componentDidMount() { - const {selectedApp} = this.props; - this.subscribeToNavigationEvents(); - this.getDevice() - .then((device) => getAppMatchPatterns(selectedApp, device)) - .then((patterns: Array) => { - this.props.setPersistedState({ - appMatchPatterns: patterns, - appMatchPatternsProvider: appMatchPatternsToAutoCompleteProvider( - patterns, - ), - }); - }) - .catch(() => { - /* Silently fail here. */ - }); - readBookmarksFromDB().then((bookmarks) => { - this.props.setPersistedState({ - bookmarks: bookmarks, - bookmarksProvider: bookmarksToAutoCompleteProvider(bookmarks), - }); + client.onMessage('nav_event', async (payload) => { + const navigationEvent: NavigationEvent = { + uri: payload.uri === undefined ? null : decodeURIComponent(payload.uri), + date: payload.date ? new Date(payload.date) : new Date(), + className: payload.class === undefined ? null : payload.class, + screenshot: null, + }; + + if (navigationEvent.uri) currentURI.set(navigationEvent.uri); + + navigationEvents.update((draft) => { + draft.unshift(navigationEvent); }); - } - navigateTo = async (query: string) => { + const screenshot: Buffer = await client.device.realDevice.screenshot(); + const blobURL = URL.createObjectURL(bufferToBlob(screenshot)); + // this process is async, make sure we update the correct one.. + const navigationEventIndex = navigationEvents + .get() + .indexOf(navigationEvent); + if (navigationEventIndex !== -1) { + navigationEvents.update((draft) => { + draft[navigationEventIndex].screenshot = blobURL; + }); + } + }); + + getAppMatchPatterns(client.appId, client.device.realDevice) + .then((patterns) => { + appMatchPatterns.set(patterns); + }) + .catch((e) => { + console.error('[Navigation] Failed to find appMatchPatterns', e); + }); + + readBookmarksFromDB().then((bookmarksData) => { + bookmarks.set(bookmarksData); + }); + + function navigateTo(query: string) { const filteredQuery = filterOptionalParameters(query); - this.props.setPersistedState({currentURI: filteredQuery}); - const requiredParameters = getRequiredParameters(filteredQuery); - if (requiredParameters.length === 0) { - const device = await this.getDevice(); - if (this.realClient.query.app === 'Facebook' && device.os === 'iOS') { + currentURI.set(filteredQuery); + const params = getRequiredParameters(filteredQuery); + if (params.length === 0) { + if (client.appName === 'Facebook' && client.device.os === 'iOS') { // use custom navigate_to event for Wilde - this.client.send('navigate_to', { + client.send('navigate_to', { url: filterOptionalParameters(filteredQuery), }); } else { - device.navigateToLocation(filterOptionalParameters(filteredQuery)); + client.device.realDevice.navigateToLocation( + filterOptionalParameters(filteredQuery), + ); } } else { - this.setState({ - requiredParameters, - shouldShowURIErrorDialog: true, - }); + requiredParameters.set(params); + shouldShowURIErrorDialog.set(true); } - }; + } - onFavorite = (uri: string) => { - this.setState({shouldShowSaveBookmarkDialog: true, saveBookmarkURI: uri}); - }; + function onFavorite(uri: string) { + // TODO: why does this need a dialog? + shouldShowSaveBookmarkDialog.set(true); + saveBookmarkURI.set(uri); + } - addBookmark = (bookmark: Bookmark) => { + function addBookmark(bookmark: Bookmark) { const newBookmark = { uri: bookmark.uri, commonName: bookmark.commonName, }; + bookmarks.update((draft) => { + draft.set(newBookmark.uri, newBookmark); + }); writeBookmarkToDB(newBookmark); - const newMapRef = this.props.persistedState.bookmarks; - newMapRef.set(newBookmark.uri, newBookmark); - this.props.setPersistedState({ - bookmarks: newMapRef, - bookmarksProvider: bookmarksToAutoCompleteProvider(newMapRef), - }); - }; - - removeBookmark = (uri: string) => { - removeBookmark(uri); - const newMapRef = this.props.persistedState.bookmarks; - newMapRef.delete(uri); - this.props.setPersistedState({ - bookmarks: newMapRef, - bookmarksProvider: bookmarksToAutoCompleteProvider(newMapRef), - }); - }; - - render() { - const { - saveBookmarkURI, - shouldShowSaveBookmarkDialog, - shouldShowURIErrorDialog, - requiredParameters, - } = this.state; - const { - bookmarks, - bookmarksProvider, - currentURI, - appMatchPatternsProvider, - navigationEvents, - } = this.props.persistedState; - const autoCompleteProviders = [bookmarksProvider, appMatchPatternsProvider]; - return ( - - - - - this.setState({shouldShowSaveBookmarkDialog: false})} - edit={ - saveBookmarkURI != null ? bookmarks.has(saveBookmarkURI) : false - } - onSubmit={this.addBookmark} - onRemove={this.removeBookmark} - /> - this.setState({shouldShowURIErrorDialog: false})} - uri={currentURI} - requiredParameters={requiredParameters} - onSubmit={this.navigateTo} - /> - - ); } + + function removeBookmark(uri: string) { + bookmarks.update((draft) => { + draft.delete(uri); + }); + removeBookmarkFromDB(uri); + } + + return { + navigateTo, + onFavorite, + addBookmark, + removeBookmark, + bookmarks, + saveBookmarkURI, + shouldShowSaveBookmarkDialog, + shouldShowURIErrorDialog, + requiredParameters, + appMatchPatterns, + navigationEvents, + currentURI, + }; +} + +export function Component() { + const instance = usePlugin(plugin); + const bookmarks = useValue(instance.bookmarks); + const appMatchPatterns = useValue(instance.appMatchPatterns); + const saveBookmarkURI = useValue(instance.saveBookmarkURI); + const shouldShowSaveBookmarkDialog = useValue( + instance.shouldShowSaveBookmarkDialog, + ); + const shouldShowURIErrorDialog = useValue(instance.shouldShowURIErrorDialog); + const requiredParameters = useValue(instance.requiredParameters); + const currentURI = useValue(instance.currentURI); + const navigationEvents = useValue(instance.navigationEvents); + + const autoCompleteProviders = useMemo( + () => [ + bookmarksToAutoCompleteProvider(bookmarks), + appMatchPatternsToAutoCompleteProvider(appMatchPatterns), + ], + [bookmarks, appMatchPatterns], + ); + return ( + + + + + { + instance.shouldShowSaveBookmarkDialog.set(false); + }} + edit={saveBookmarkURI != null ? bookmarks.has(saveBookmarkURI) : false} + onSubmit={instance.addBookmark} + onRemove={instance.removeBookmark} + /> + { + instance.shouldShowURIErrorDialog.set(false); + }} + uri={currentURI} + requiredParameters={requiredParameters} + onSubmit={instance.navigateTo} + /> + + ); } /* @scarf-info: do not remove, more info: https://fburl.com/scarf */ diff --git a/desktop/plugins/navigation/package.json b/desktop/plugins/navigation/package.json index 2776741fb..fbbd7bd96 100644 --- a/desktop/plugins/navigation/package.json +++ b/desktop/plugins/navigation/package.json @@ -13,5 +13,8 @@ "icon": "directions", "bugs": { "email": "beneloca@fb.com" + }, + "peerDependencies": { + "flipper-plugin": "0.64.0" } } diff --git a/desktop/plugins/navigation/types.tsx b/desktop/plugins/navigation/types.tsx index 027922266..652c5a329 100644 --- a/desktop/plugins/navigation/types.tsx +++ b/desktop/plugins/navigation/types.tsx @@ -9,20 +9,11 @@ export type URI = string; -export type State = { - shouldShowSaveBookmarkDialog: boolean; - shouldShowURIErrorDialog: boolean; - saveBookmarkURI: URI | null; - requiredParameters: Array; -}; - -export type PersistedState = { - bookmarks: Map; - navigationEvents: Array; - bookmarksProvider: AutoCompleteProvider; - appMatchPatterns: Array; - appMatchPatternsProvider: AutoCompleteProvider; - currentURI: string; +export type RawNavigationEvent = { + date: string | undefined; + uri: URI | undefined; + class: string | undefined; + screenshot: string | undefined; }; export type NavigationEvent = { diff --git a/desktop/plugins/navigation/util/indexedDB.tsx b/desktop/plugins/navigation/util/indexedDB.tsx index 1330fc7fa..1b7d79db4 100644 --- a/desktop/plugins/navigation/util/indexedDB.tsx +++ b/desktop/plugins/navigation/util/indexedDB.tsx @@ -88,7 +88,7 @@ export const readBookmarksFromDB: () => Promise> = () => { }); }; -export const removeBookmark: (uri: string) => Promise = (uri) => { +export const removeBookmarkFromDB: (uri: string) => Promise = (uri) => { return new Promise((resolve, reject) => { openNavigationPluginDB() .then((db: IDBDatabase) => { diff --git a/desktop/static/icons.json b/desktop/static/icons.json index 8ce5ba34d..b2b04274e 100644 --- a/desktop/static/icons.json +++ b/desktop/static/icons.json @@ -532,5 +532,11 @@ ], "hourglass": [ 16 + ], + "mobile-outline": [ + 16 + ], + "bookmark-outline": [ + 16 ] } \ No newline at end of file