mirror of https://github.com/vector-im/riot-web
				
				
				
			Live Location Sharing - left panel warning with error (#8201)
* add error style to left panel beacon warning Signed-off-by: Kerry Archibald <kerrya@element.io> * test Signed-off-by: Kerry Archibald <kerrya@element.io> * add beacon sort util * link to latest beacon room from left panel warning Signed-off-by: Kerry Archibald <kerrya@element.io>pull/21833/head
							parent
							
								
									1175226bcb
								
							
						
					
					
						commit
						4922e19b5a
					
				|  | @ -15,6 +15,7 @@ limitations under the License. | |||
| */ | ||||
| 
 | ||||
| .mx_LeftPanelLiveShareWarning { | ||||
|     @mixin ButtonResetDefault; | ||||
|     width: 100%; | ||||
|     box-sizing: border-box; | ||||
| 
 | ||||
|  | @ -29,3 +30,7 @@ limitations under the License. | |||
|     // go above to get hover for title | ||||
|     z-index: 1; | ||||
| } | ||||
| 
 | ||||
| .mx_LeftPanelLiveShareWarning__error { | ||||
|     background-color: $alert; | ||||
| } | ||||
|  |  | |||
|  | @ -21,11 +21,31 @@ import { useEventEmitterState } from '../../../hooks/useEventEmitter'; | |||
| import { _t } from '../../../languageHandler'; | ||||
| import { OwnBeaconStore, OwnBeaconStoreEvent } from '../../../stores/OwnBeaconStore'; | ||||
| import { Icon as LiveLocationIcon } from '../../../../res/img/location/live-location.svg'; | ||||
| import { ViewRoomPayload } from '../../../dispatcher/payloads/ViewRoomPayload'; | ||||
| import { Action } from '../../../dispatcher/actions'; | ||||
| import dispatcher from '../../../dispatcher/dispatcher'; | ||||
| import AccessibleButton from '../elements/AccessibleButton'; | ||||
| 
 | ||||
| interface Props { | ||||
|     isMinimized?: boolean; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Choose the most relevant beacon | ||||
|  * and get its roomId | ||||
|  */ | ||||
| const chooseBestBeaconRoomId = (liveBeaconIds, errorBeaconIds): string | undefined => { | ||||
|     // both lists are ordered by creation timestamp in store
 | ||||
|     // so select latest beacon
 | ||||
|     const beaconId = errorBeaconIds?.[0] ?? liveBeaconIds?.[0]; | ||||
|     if (!beaconId) { | ||||
|         return undefined; | ||||
|     } | ||||
|     const beacon = OwnBeaconStore.instance.getBeaconById(beaconId); | ||||
| 
 | ||||
|     return beacon?.roomId; | ||||
| }; | ||||
| 
 | ||||
| const LeftPanelLiveShareWarning: React.FC<Props> = ({ isMinimized }) => { | ||||
|     const isMonitoringLiveLocation = useEventEmitterState( | ||||
|         OwnBeaconStore.instance, | ||||
|  | @ -33,18 +53,48 @@ const LeftPanelLiveShareWarning: React.FC<Props> = ({ isMinimized }) => { | |||
|         () => OwnBeaconStore.instance.isMonitoringLiveLocation, | ||||
|     ); | ||||
| 
 | ||||
|     const beaconIdsWithWireError = useEventEmitterState( | ||||
|         OwnBeaconStore.instance, | ||||
|         OwnBeaconStoreEvent.WireError, | ||||
|         () => OwnBeaconStore.instance.getLiveBeaconIdsWithWireError(), | ||||
|     ); | ||||
| 
 | ||||
|     const liveBeaconIds = useEventEmitterState( | ||||
|         OwnBeaconStore.instance, | ||||
|         OwnBeaconStoreEvent.LivenessChange, | ||||
|         () => OwnBeaconStore.instance.getLiveBeaconIds(), | ||||
|     ); | ||||
| 
 | ||||
|     const hasWireErrors = !!beaconIdsWithWireError.length; | ||||
| 
 | ||||
|     if (!isMonitoringLiveLocation) { | ||||
|         return null; | ||||
|     } | ||||
| 
 | ||||
|     return <div | ||||
|     const relevantBeaconRoomId = chooseBestBeaconRoomId(liveBeaconIds, beaconIdsWithWireError); | ||||
| 
 | ||||
|     const onWarningClick = relevantBeaconRoomId ? () => { | ||||
|         dispatcher.dispatch<ViewRoomPayload>({ | ||||
|             action: Action.ViewRoom, | ||||
|             room_id: relevantBeaconRoomId, | ||||
|             metricsTrigger: undefined, | ||||
|         }); | ||||
|     } : undefined; | ||||
| 
 | ||||
|     const label = hasWireErrors ? | ||||
|         _t('An error occured whilst sharing your live location') : | ||||
|         _t('You are sharing your live location'); | ||||
| 
 | ||||
|     return <AccessibleButton | ||||
|         className={classNames('mx_LeftPanelLiveShareWarning', { | ||||
|             'mx_LeftPanelLiveShareWarning__minimized': isMinimized, | ||||
|             'mx_LeftPanelLiveShareWarning__error': hasWireErrors, | ||||
|         })} | ||||
|         title={isMinimized ? _t('You are sharing your live location') : undefined} | ||||
|         title={isMinimized ? label : undefined} | ||||
|         onClick={onWarningClick} | ||||
|     > | ||||
|         { isMinimized ? <LiveLocationIcon height={10} /> : _t('You are sharing your live location') } | ||||
|     </div>; | ||||
|         { isMinimized ? <LiveLocationIcon height={10} /> : label } | ||||
|     </AccessibleButton>; | ||||
| }; | ||||
| 
 | ||||
| export default LeftPanelLiveShareWarning; | ||||
|  |  | |||
|  | @ -2896,6 +2896,7 @@ | |||
|     "Beta": "Beta", | ||||
|     "Leave the beta": "Leave the beta", | ||||
|     "Join the beta": "Join the beta", | ||||
|     "An error occured whilst sharing your live location": "An error occured whilst sharing your live location", | ||||
|     "You are sharing your live location": "You are sharing your live location", | ||||
|     "%(timeRemaining)s left": "%(timeRemaining)s left", | ||||
|     "An error occured whilst sharing your live location, please try again": "An error occured whilst sharing your live location, please try again", | ||||
|  |  | |||
|  | @ -38,6 +38,7 @@ import { | |||
|     ClearWatchCallback, | ||||
|     GeolocationError, | ||||
|     mapGeolocationPositionToTimedGeo, | ||||
|     sortBeaconsByLatestCreation, | ||||
|     TimedGeoUri, | ||||
|     watchPosition, | ||||
| } from "../utils/beacon"; | ||||
|  | @ -73,6 +74,10 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> { | |||
|      * Reset on successful publish of location | ||||
|      */ | ||||
|     public readonly beaconWireErrorCounts = new Map<string, number>(); | ||||
|     /** | ||||
|      * ids of live beacons | ||||
|      * ordered by creation time descending | ||||
|      */ | ||||
|     private liveBeaconIds = []; | ||||
|     private locationInterval: number; | ||||
|     private geolocationError: GeolocationError | undefined; | ||||
|  | @ -126,17 +131,17 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> { | |||
|         // we don't actually do anything here
 | ||||
|     } | ||||
| 
 | ||||
|     public hasLiveBeacons(roomId?: string): boolean { | ||||
|     public hasLiveBeacons = (roomId?: string): boolean => { | ||||
|         return !!this.getLiveBeaconIds(roomId).length; | ||||
|     } | ||||
|     }; | ||||
| 
 | ||||
|     /** | ||||
|      * Some live beacon has a wire error | ||||
|      * Optionally filter by room | ||||
|      */ | ||||
|     public hasWireErrors(roomId?: string): boolean { | ||||
|     public hasWireErrors = (roomId?: string): boolean => { | ||||
|         return this.getLiveBeaconIds(roomId).some(this.beaconHasWireError); | ||||
|     } | ||||
|     }; | ||||
| 
 | ||||
|     /** | ||||
|      * If a beacon has failed to publish position | ||||
|  | @ -157,16 +162,20 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> { | |||
|         this.publishCurrentLocationToBeacons(); | ||||
|     }; | ||||
| 
 | ||||
|     public getLiveBeaconIds(roomId?: string): string[] { | ||||
|     public getLiveBeaconIds = (roomId?: string): string[] => { | ||||
|         if (!roomId) { | ||||
|             return this.liveBeaconIds; | ||||
|         } | ||||
|         return this.liveBeaconIds.filter(beaconId => this.beaconsByRoomId.get(roomId)?.has(beaconId)); | ||||
|     } | ||||
|     }; | ||||
| 
 | ||||
|     public getBeaconById(beaconId: string): Beacon | undefined { | ||||
|     public getLiveBeaconIdsWithWireError = (roomId?: string): string[] => { | ||||
|         return this.getLiveBeaconIds(roomId).filter(this.beaconHasWireError); | ||||
|     }; | ||||
| 
 | ||||
|     public getBeaconById = (beaconId: string): Beacon | undefined => { | ||||
|         return this.beacons.get(beaconId); | ||||
|     } | ||||
|     }; | ||||
| 
 | ||||
|     public stopBeacon = async (beaconInfoType: string): Promise<void> => { | ||||
|         const beacon = this.beacons.get(beaconInfoType); | ||||
|  | @ -287,6 +296,7 @@ export class OwnBeaconStore extends AsyncStoreWithClient<OwnBeaconStoreState> { | |||
|         const prevLiveBeaconIds = this.getLiveBeaconIds(); | ||||
|         this.liveBeaconIds = [...this.beacons.values()] | ||||
|             .filter(beacon => beacon.isLive) | ||||
|             .sort(sortBeaconsByLatestCreation) | ||||
|             .map(beacon => beacon.identifier); | ||||
| 
 | ||||
|         const diff = arrayDiff(prevLiveBeaconIds, this.liveBeaconIds); | ||||
|  |  | |||
|  | @ -34,3 +34,7 @@ export const getBeaconExpiryTimestamp = (beacon: Beacon): number => | |||
| 
 | ||||
| export const sortBeaconsByLatestExpiry = (left: Beacon, right: Beacon): number => | ||||
|     getBeaconExpiryTimestamp(right) - getBeaconExpiryTimestamp(left); | ||||
| 
 | ||||
| // aka sort by timestamp descending
 | ||||
| export const sortBeaconsByLatestCreation = (left: Beacon, right: Beacon): number => | ||||
|     right.beaconInfo.timestamp - left.beaconInfo.timestamp; | ||||
|  |  | |||
|  | @ -17,17 +17,23 @@ limitations under the License. | |||
| import React from 'react'; | ||||
| import { mocked } from 'jest-mock'; | ||||
| import { mount } from 'enzyme'; | ||||
| import { act } from 'react-dom/test-utils'; | ||||
| import { Beacon } from 'matrix-js-sdk/src/matrix'; | ||||
| 
 | ||||
| import '../../../skinned-sdk'; | ||||
| import LeftPanelLiveShareWarning from '../../../../src/components/views/beacon/LeftPanelLiveShareWarning'; | ||||
| import { OwnBeaconStore, OwnBeaconStoreEvent } from '../../../../src/stores/OwnBeaconStore'; | ||||
| import { flushPromises } from '../../../test-utils'; | ||||
| import { flushPromises, makeBeaconInfoEvent } from '../../../test-utils'; | ||||
| import dispatcher from '../../../../src/dispatcher/dispatcher'; | ||||
| import { Action } from '../../../../src/dispatcher/actions'; | ||||
| 
 | ||||
| jest.mock('../../../../src/stores/OwnBeaconStore', () => { | ||||
|     // eslint-disable-next-line @typescript-eslint/no-var-requires
 | ||||
|     const EventEmitter = require("events"); | ||||
|     class MockOwnBeaconStore extends EventEmitter { | ||||
|         public hasLiveBeacons = jest.fn().mockReturnValue(false); | ||||
|         public getLiveBeaconIdsWithWireError = jest.fn().mockReturnValue([]); | ||||
|         public getBeaconById = jest.fn(); | ||||
|         public getLiveBeaconIds = jest.fn().mockReturnValue([]); | ||||
|     } | ||||
|     return { | ||||
|         // @ts-ignore
 | ||||
|  | @ -44,32 +50,136 @@ describe('<LeftPanelLiveShareWarning />', () => { | |||
|     const getComponent = (props = {}) => | ||||
|         mount(<LeftPanelLiveShareWarning {...defaultProps} {...props} />); | ||||
| 
 | ||||
|     const roomId1 = '!room1:server'; | ||||
|     const roomId2 = '!room2:server'; | ||||
|     const aliceId = '@alive:server'; | ||||
| 
 | ||||
|     const now = 1647270879403; | ||||
|     const HOUR_MS = 3600000; | ||||
| 
 | ||||
|     beforeEach(() => { | ||||
|         jest.spyOn(global.Date, 'now').mockReturnValue(now); | ||||
|         jest.spyOn(dispatcher, 'dispatch').mockClear().mockImplementation(() => { }); | ||||
|     }); | ||||
| 
 | ||||
|     afterAll(() => { | ||||
|         jest.spyOn(global.Date, 'now').mockRestore(); | ||||
| 
 | ||||
|         jest.restoreAllMocks(); | ||||
|     }); | ||||
|     // 12h old, 12h left
 | ||||
|     const beacon1 = new Beacon(makeBeaconInfoEvent(aliceId, | ||||
|         roomId1, | ||||
|         { timeout: HOUR_MS * 24, timestamp: now - 12 * HOUR_MS }, | ||||
|         '$1', | ||||
|     )); | ||||
|     // 10h left
 | ||||
|     const beacon2 = new Beacon(makeBeaconInfoEvent(aliceId, | ||||
|         roomId2, | ||||
|         { timeout: HOUR_MS * 10, timestamp: now }, | ||||
|         '$2', | ||||
|     )); | ||||
| 
 | ||||
|     it('renders nothing when user has no live beacons', () => { | ||||
|         const component = getComponent(); | ||||
|         expect(component.html()).toBe(null); | ||||
|     }); | ||||
| 
 | ||||
|     describe('when user has live location monitor', () => { | ||||
|         beforeAll(() => { | ||||
|             mocked(OwnBeaconStore.instance).getBeaconById.mockImplementation(beaconId => { | ||||
|                 if (beaconId === beacon1.identifier) { | ||||
|                     return beacon1; | ||||
|                 } | ||||
|                 return beacon2; | ||||
|             }); | ||||
|         }); | ||||
| 
 | ||||
|         beforeEach(() => { | ||||
|             mocked(OwnBeaconStore.instance).isMonitoringLiveLocation = true; | ||||
|             mocked(OwnBeaconStore.instance).getLiveBeaconIdsWithWireError.mockReturnValue([]); | ||||
|             mocked(OwnBeaconStore.instance).getLiveBeaconIds.mockReturnValue([beacon2.identifier, beacon1.identifier]); | ||||
|         }); | ||||
| 
 | ||||
|         it('renders correctly when not minimized', () => { | ||||
|             const component = getComponent(); | ||||
|             expect(component).toMatchSnapshot(); | ||||
|         }); | ||||
| 
 | ||||
|         it('goes to room of latest beacon when clicked', () => { | ||||
|             const component = getComponent(); | ||||
|             const dispatchSpy = jest.spyOn(dispatcher, 'dispatch'); | ||||
| 
 | ||||
|             act(() => { | ||||
|                 component.simulate('click'); | ||||
|             }); | ||||
| 
 | ||||
|             expect(dispatchSpy).toHaveBeenCalledWith({ | ||||
|                 action: Action.ViewRoom, | ||||
|                 metricsTrigger: undefined, | ||||
|                 // latest beacon's room
 | ||||
|                 room_id: roomId2, | ||||
|             }); | ||||
|         }); | ||||
| 
 | ||||
|         it('renders correctly when minimized', () => { | ||||
|             const component = getComponent({ isMinimized: true }); | ||||
|             expect(component).toMatchSnapshot(); | ||||
|         }); | ||||
| 
 | ||||
|         it('renders wire error', () => { | ||||
|             mocked(OwnBeaconStore.instance).getLiveBeaconIdsWithWireError.mockReturnValue([beacon1.identifier]); | ||||
|             const component = getComponent(); | ||||
|             expect(component).toMatchSnapshot(); | ||||
|         }); | ||||
| 
 | ||||
|         it('goes to room of latest beacon with wire error when clicked', () => { | ||||
|             mocked(OwnBeaconStore.instance).getLiveBeaconIdsWithWireError.mockReturnValue([beacon1.identifier]); | ||||
|             const component = getComponent(); | ||||
|             const dispatchSpy = jest.spyOn(dispatcher, 'dispatch'); | ||||
| 
 | ||||
|             act(() => { | ||||
|                 component.simulate('click'); | ||||
|             }); | ||||
| 
 | ||||
|             expect(dispatchSpy).toHaveBeenCalledWith({ | ||||
|                 action: Action.ViewRoom, | ||||
|                 metricsTrigger: undefined, | ||||
|                 // error beacon's room
 | ||||
|                 room_id: roomId1, | ||||
|             }); | ||||
|         }); | ||||
| 
 | ||||
|         it('goes back to default style when wire errors are cleared', () => { | ||||
|             mocked(OwnBeaconStore.instance).getLiveBeaconIdsWithWireError.mockReturnValue([beacon1.identifier]); | ||||
|             const component = getComponent(); | ||||
|             // error mode
 | ||||
|             expect(component.find('.mx_LeftPanelLiveShareWarning').at(0).text()).toEqual( | ||||
|                 'An error occured whilst sharing your live location', | ||||
|             ); | ||||
| 
 | ||||
|             act(() => { | ||||
|                 mocked(OwnBeaconStore.instance).getLiveBeaconIdsWithWireError.mockReturnValue([]); | ||||
|                 OwnBeaconStore.instance.emit(OwnBeaconStoreEvent.WireError, 'abc'); | ||||
|             }); | ||||
| 
 | ||||
|             component.setProps({}); | ||||
| 
 | ||||
|             // default mode
 | ||||
|             expect(component.find('.mx_LeftPanelLiveShareWarning').at(0).text()).toEqual( | ||||
|                 'You are sharing your live location', | ||||
|             ); | ||||
|         }); | ||||
| 
 | ||||
|         it('removes itself when user stops having live beacons', async () => { | ||||
|             const component = getComponent({ isMinimized: true }); | ||||
|             // started out rendered
 | ||||
|             expect(component.html()).toBeTruthy(); | ||||
| 
 | ||||
|             mocked(OwnBeaconStore.instance).isMonitoringLiveLocation = false; | ||||
|             OwnBeaconStore.instance.emit(OwnBeaconStoreEvent.MonitoringLivePosition); | ||||
|             act(() => { | ||||
|                 mocked(OwnBeaconStore.instance).isMonitoringLiveLocation = false; | ||||
|                 OwnBeaconStore.instance.emit(OwnBeaconStoreEvent.MonitoringLivePosition); | ||||
|             }); | ||||
| 
 | ||||
|             await flushPromises(); | ||||
|             component.setProps({}); | ||||
|  |  | |||
|  | @ -4,23 +4,73 @@ exports[`<LeftPanelLiveShareWarning /> when user has live location monitor rende | |||
| <LeftPanelLiveShareWarning | ||||
|   isMinimized={true} | ||||
| > | ||||
|   <div | ||||
|   <AccessibleButton | ||||
|     className="mx_LeftPanelLiveShareWarning mx_LeftPanelLiveShareWarning__minimized" | ||||
|     element="div" | ||||
|     onClick={[Function]} | ||||
|     role="button" | ||||
|     tabIndex={0} | ||||
|     title="You are sharing your live location" | ||||
|   > | ||||
|     <div | ||||
|       height={10} | ||||
|     /> | ||||
|   </div> | ||||
|       className="mx_AccessibleButton mx_LeftPanelLiveShareWarning mx_LeftPanelLiveShareWarning__minimized" | ||||
|       onClick={[Function]} | ||||
|       onKeyDown={[Function]} | ||||
|       onKeyUp={[Function]} | ||||
|       role="button" | ||||
|       tabIndex={0} | ||||
|       title="You are sharing your live location" | ||||
|     > | ||||
|       <div | ||||
|         height={10} | ||||
|       /> | ||||
|     </div> | ||||
|   </AccessibleButton> | ||||
| </LeftPanelLiveShareWarning> | ||||
| `; | ||||
| 
 | ||||
| exports[`<LeftPanelLiveShareWarning /> when user has live location monitor renders correctly when not minimized 1`] = ` | ||||
| <LeftPanelLiveShareWarning> | ||||
|   <div | ||||
|   <AccessibleButton | ||||
|     className="mx_LeftPanelLiveShareWarning" | ||||
|     element="div" | ||||
|     onClick={[Function]} | ||||
|     role="button" | ||||
|     tabIndex={0} | ||||
|   > | ||||
|     You are sharing your live location | ||||
|   </div> | ||||
|     <div | ||||
|       className="mx_AccessibleButton mx_LeftPanelLiveShareWarning" | ||||
|       onClick={[Function]} | ||||
|       onKeyDown={[Function]} | ||||
|       onKeyUp={[Function]} | ||||
|       role="button" | ||||
|       tabIndex={0} | ||||
|     > | ||||
|       You are sharing your live location | ||||
|     </div> | ||||
|   </AccessibleButton> | ||||
| </LeftPanelLiveShareWarning> | ||||
| `; | ||||
| 
 | ||||
| exports[`<LeftPanelLiveShareWarning /> when user has live location monitor renders wire error 1`] = ` | ||||
| <LeftPanelLiveShareWarning> | ||||
|   <AccessibleButton | ||||
|     className="mx_LeftPanelLiveShareWarning mx_LeftPanelLiveShareWarning__error" | ||||
|     element="div" | ||||
|     onClick={[Function]} | ||||
|     role="button" | ||||
|     tabIndex={0} | ||||
|   > | ||||
|     <div | ||||
|       className="mx_AccessibleButton mx_LeftPanelLiveShareWarning mx_LeftPanelLiveShareWarning__error" | ||||
|       onClick={[Function]} | ||||
|       onKeyDown={[Function]} | ||||
|       onKeyUp={[Function]} | ||||
|       role="button" | ||||
|       tabIndex={0} | ||||
|     > | ||||
|       An error occured whilst sharing your live location | ||||
|     </div> | ||||
|   </AccessibleButton> | ||||
| </LeftPanelLiveShareWarning> | ||||
| `; | ||||
|  |  | |||
|  | @ -16,7 +16,11 @@ limitations under the License. | |||
| 
 | ||||
| import { Beacon } from "matrix-js-sdk/src/matrix"; | ||||
| 
 | ||||
| import { msUntilExpiry, sortBeaconsByLatestExpiry } from "../../../src/utils/beacon"; | ||||
| import { | ||||
|     msUntilExpiry, | ||||
|     sortBeaconsByLatestExpiry, | ||||
|     sortBeaconsByLatestCreation, | ||||
| } from "../../../src/utils/beacon"; | ||||
| import { makeBeaconInfoEvent } from "../../test-utils"; | ||||
| 
 | ||||
| describe('beacon utils', () => { | ||||
|  | @ -80,4 +84,35 @@ describe('beacon utils', () => { | |||
|             ]); | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
|     describe('sortBeaconsByLatestCreation()', () => { | ||||
|         const roomId = '!room:server'; | ||||
|         const aliceId = '@alive:server'; | ||||
| 
 | ||||
|         // 12h old, 12h left
 | ||||
|         const beacon1 = new Beacon(makeBeaconInfoEvent(aliceId, | ||||
|             roomId, | ||||
|             { timeout: HOUR_MS * 24, timestamp: now - 12 * HOUR_MS }, | ||||
|             '$1', | ||||
|         )); | ||||
|         // 10h left
 | ||||
|         const beacon2 = new Beacon(makeBeaconInfoEvent(aliceId, | ||||
|             roomId, | ||||
|             { timeout: HOUR_MS * 10, timestamp: now }, | ||||
|             '$2', | ||||
|         )); | ||||
| 
 | ||||
|         // 1ms left
 | ||||
|         const beacon3 = new Beacon(makeBeaconInfoEvent(aliceId, | ||||
|             roomId, | ||||
|             { timeout: HOUR_MS + 1, timestamp: now - HOUR_MS }, | ||||
|             '$3', | ||||
|         )); | ||||
| 
 | ||||
|         it('sorts beacons by descending creation time', () => { | ||||
|             expect([beacon1, beacon2, beacon3].sort(sortBeaconsByLatestCreation)).toEqual([ | ||||
|                 beacon2, beacon3, beacon1, | ||||
|             ]); | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
|  |  | |||
		Loading…
	
		Reference in New Issue
	
	 Kerry
						Kerry