diff --git a/ACKNOWLEDGMENTS.md b/ACKNOWLEDGMENTS.md index f73aa0a87f37..aba8bb61847d 100644 --- a/ACKNOWLEDGMENTS.md +++ b/ACKNOWLEDGMENTS.md @@ -1301,6 +1301,24 @@ Signal Desktop makes use of the following open source projects. licenses; we recommend you read them, as their terms may differ from the terms above. +## lru-cache + + The ISC License + + Copyright (c) Isaac Z. Schlueter and Contributors + + Permission to use, copy, modify, and/or distribute this software for any + purpose with or without fee is hereby granted, provided that the above + copyright notice and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR + IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + ## memoizee ISC License diff --git a/_locales/en/messages.json b/_locales/en/messages.json index b9714032b04b..a008ad3bcf17 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -5042,5 +5042,17 @@ "cannotSelectContact": { "message": "Cannot select contact", "description": "The label for contact checkboxes that are disabled" + }, + "MessageAudio--play": { + "message": "Play audio attachment", + "description": "Aria label for audio attachment's Play button" + }, + "MessageAudio--pause": { + "message": "Pause audio attachment", + "description": "Aria label for audio attachment's Pause button" + }, + "MessageAudio--slider": { + "message": "Playback time of audio attachment", + "description": "Aria label for audio attachment's playback time slider" } } diff --git a/images/icons/v2/pause-solid-20.svg b/images/icons/v2/pause-solid-20.svg new file mode 100644 index 000000000000..6394f810075d --- /dev/null +++ b/images/icons/v2/pause-solid-20.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v2/play-solid-20.svg b/images/icons/v2/play-solid-20.svg new file mode 100644 index 000000000000..02c230586810 --- /dev/null +++ b/images/icons/v2/play-solid-20.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/package.json b/package.json index a0691c6ea4b5..e9ce10c7d796 100644 --- a/package.json +++ b/package.json @@ -93,8 +93,10 @@ "intl-tel-input": "12.1.15", "jquery": "3.5.0", "js-yaml": "3.13.1", + "libsignal-client": "https://github.com/signalapp/libsignal-client-node.git#f10fbd04eb6efb396eb12c25429761e8785dc9d0", "linkify-it": "2.2.0", "lodash": "4.17.20", + "lru-cache": "6.0.0", "memoizee": "0.4.14", "mkdirp": "0.5.2", "moment": "2.21.0", @@ -147,8 +149,7 @@ "underscore": "1.9.0", "uuid": "3.3.2", "websocket": "1.0.28", - "zkgroup": "https://github.com/signalapp/signal-zkgroup-node.git#2d7db946cc88492b65cc66e9aa9de0c9e664fd8d", - "libsignal-client": "https://github.com/signalapp/libsignal-client-node.git#f10fbd04eb6efb396eb12c25429761e8785dc9d0" + "zkgroup": "https://github.com/signalapp/signal-zkgroup-node.git#2d7db946cc88492b65cc66e9aa9de0c9e664fd8d" }, "devDependencies": { "@babel/core": "7.7.7", @@ -178,6 +179,7 @@ "@types/linkify-it": "2.1.0", "@types/lodash": "4.14.106", "@types/long": "4.0.1", + "@types/lru-cache": "5.1.0", "@types/memoizee": "0.4.2", "@types/mkdirp": "0.5.2", "@types/mocha": "5.0.0", diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 60aadeeea5c0..16ac35c52e97 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -686,18 +686,6 @@ cursor: pointer; } -.module-message__audio-attachment { - margin-top: 2px; -} - -.module-message__audio-attachment--with-content-below { - margin-bottom: 5px; -} - -.module-message__audio-attachment--with-content-above { - margin-top: 6px; -} - .module-message__generic-attachment { @include button-reset; diff --git a/stylesheets/_variables.scss b/stylesheets/_variables.scss index 7304491cf637..8542077a614e 100644 --- a/stylesheets/_variables.scss +++ b/stylesheets/_variables.scss @@ -30,13 +30,16 @@ $color-black: #000000; $color-white-alpha-20: rgba($color-white, 0.2); $color-white-alpha-40: rgba($color-white, 0.4); $color-white-alpha-60: rgba($color-white, 0.6); +$color-white-alpha-70: rgba($color-white, 0.7); $color-white-alpha-80: rgba($color-white, 0.8); $color-white-alpha-90: rgba($color-white, 0.9); $color-black-alpha-05: rgba($color-black, 0.05); $color-black-alpha-20: rgba($color-black, 0.2); $color-black-alpha-40: rgba($color-black, 0.4); +$color-black-alpha-50: rgba($color-black, 0.5); $color-black-alpha-60: rgba($color-black, 0.6); +$color-black-alpha-80: rgba($color-black, 0.8); $ultramarine-brand-light: #3a76f0; $ultramarine-brand-dark: #1851b4; diff --git a/stylesheets/components/MessageAudio.scss b/stylesheets/components/MessageAudio.scss new file mode 100644 index 000000000000..c0b80ae612d2 --- /dev/null +++ b/stylesheets/components/MessageAudio.scss @@ -0,0 +1,282 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-message__audio-attachment { + display: flex; + + flex-direction: row; + align-items: center; + + margin-top: 2px; +} + +/* The separator between audio and text */ +.module-message__audio-attachment--with-content-below { + border-bottom: 1px solid $color-white-alpha-20; + padding-bottom: 12px; + margin-bottom: 7px; + + .module-message__audio-attachment--incoming & { + @mixin android { + border-color: $color-white-alpha-20; + } + + @include light-theme { + @include android; + } + @include dark-theme { + @include android; + } + @include ios-theme { + border-color: $color-black-alpha-20; + } + @include ios-dark-theme { + border-color: $color-white-alpha-20; + } + } + + .module-message__container--outgoing & { + @mixin ios { + border-color: $color-white-alpha-20; + } + + @include light-theme { + border-color: $color-black-alpha-20; + } + @include dark-theme { + border-color: $color-white-alpha-20; + } + @include ios-theme { + @include ios; + } + @include ios-dark-theme { + @include ios; + } + } +} + +.module-message__audio-attachment--with-content-above { + margin-top: 6px; +} + +.module-message__audio-attachment__button { + flex-shrink: 0; + width: 36px; + height: 36px; + + @include button-reset; + + outline: none; + border-radius: 18px; + + &::before { + display: block; + height: 100%; + content: ''; + } + + @mixin audio-icon($name, $color) { + &--#{$name}::before { + @include color-svg( + '../images/icons/v2/#{$name}-solid-20.svg', + $color, + false + ); + } + } + + .module-message__audio-attachment--incoming & { + @mixin android { + background: $color-white-alpha-20; + + @include audio-icon(play, $color-white); + @include audio-icon(pause, $color-white); + } + + @include light-theme { + @include android; + } + @include dark-theme { + @include android; + } + @include ios-theme { + background: $color-white; + + @include audio-icon(play, $color-gray-60); + @include audio-icon(pause, $color-gray-60); + } + @include ios-dark-theme { + background: $color-gray-60; + + @include audio-icon(play, $color-gray-15); + @include audio-icon(pause, $color-gray-15); + } + } + + .module-message__audio-attachment--outgoing & { + @mixin android { + background: $color-white; + + @include audio-icon(play, $color-gray-60); + @include audio-icon(pause, $color-gray-60); + } + + @mixin ios { + background: $color-white-alpha-20; + + @include audio-icon(play, $color-white); + @include audio-icon(pause, $color-white); + } + + @include light-theme { + @include android; + } + @include dark-theme { + @include android; + } + @include ios-theme { + @include ios; + } + @include ios-dark-theme { + @include ios; + } + } +} + +.module-message__audio-attachment__waveform { + flex-shrink: 0; + margin-left: 12px; + + display: flex; + align-items: center; + + outline: 0; +} + +.module-message__audio-attachment__waveform__bar { + display: inline-block; + + width: 2px; + border-radius: 2px; + transition: height 250ms, background 250ms; + + &:not(:first-of-type) { + margin-left: 2px; + } + + .module-message__audio-attachment--incoming & { + @mixin android { + background: $color-white-alpha-40; + &--active { + background: $color-white-alpha-80; + } + } + + @include light-theme { + @include android; + } + @include dark-theme { + @include android; + } + @include ios-theme { + background: $color-black-alpha-40; + &--active { + background: $color-black-alpha-80; + } + } + @include ios-dark-theme { + background: $color-white-alpha-40; + &--active { + background: $color-white-alpha-70; + } + } + } + + .module-message__audio-attachment--outgoing & { + @mixin ios { + background: $color-white-alpha-40; + &--active { + background: $color-white-alpha-80; + } + } + + @include light-theme { + background: $color-black-alpha-20; + &--active { + background: $color-black-alpha-50; + } + } + @include dark-theme { + background: $color-white-alpha-40; + &--active { + background: $color-white-alpha-80; + } + } + @include ios-theme { + @include ios; + } + @include ios-dark-theme { + @include ios; + } + } +} + +.module-message__audio-attachment__duration { + flex-shrink: 1; + margin-left: 12px; + + @include font-caption; + + .module-message__audio-attachment--incoming & { + @mixin android { + color: $color-white-alpha-80; + } + @include light-theme { + @include android; + } + @include dark-theme { + @include android; + } + @include ios-theme { + color: $color-black-alpha-60; + } + @include ios-dark-theme { + color: $color-white-alpha-80; + } + } + + .module-message__audio-attachment--outgoing & { + @mixin ios { + color: $color-white-alpha-80; + } + + @include light-theme { + color: $color-gray-60; + } + @include dark-theme { + color: $color-white-alpha-80; + } + @include ios-theme { + @include ios; + } + @include ios-dark-theme { + @include ios; + } + } +} + +@media (min-width: 0px) and (max-width: 799px) { + .module-message__audio-attachment__waveform { + margin-left: 4px; + } + + /* Clip the duration text when it is too long on small screens */ + .module-message__audio-attachment__duration { + margin-left: 4px; + + max-width: 46px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index a58c3d57cf76..3d13963e6de9 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -36,3 +36,4 @@ @import './components/EditConversationAttributesModal.scss'; @import './components/GroupDialog.scss'; @import './components/GroupTitleInput.scss'; +@import './components/MessageAudio.scss'; diff --git a/ts/components/GlobalAudioContext.tsx b/ts/components/GlobalAudioContext.tsx new file mode 100644 index 000000000000..dac1438a1742 --- /dev/null +++ b/ts/components/GlobalAudioContext.tsx @@ -0,0 +1,78 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; +import LRU from 'lru-cache'; + +import { WaveformCache } from '../types/Audio'; + +const MAX_WAVEFORM_COUNT = 1000; + +type Contents = { + audio: HTMLAudioElement; + audioContext: AudioContext; + waveformCache: WaveformCache; +}; + +export const GlobalAudioContext = React.createContext(null); + +export type GlobalAudioProps = { + conversationId: string; + children?: React.ReactNode | React.ReactChildren; +}; + +/** + * A global context that holds Audio, AudioContext, LRU instances that are used + * inside the conversation by ts/components/conversation/MessageAudio.tsx + */ +export const GlobalAudioProvider: React.FC = ({ + conversationId, + children, +}) => { + const audio = React.useMemo(() => { + window.log.info( + 'GlobalAudioProvider: re-generating audio for', + conversationId + ); + return new Audio(); + }, [conversationId]); + + // NOTE: the number of active audio contexts is limited per tab/window + // See: https://developer.mozilla.org/en-US/docs/Web/API/AudioContext/AudioContext#google_chrome + const audioContext = React.useMemo(() => { + window.log.info('Instantiating new audio context'); + return new AudioContext(); + }, []); + + const waveformCache: WaveformCache = React.useMemo(() => { + return new LRU({ + max: MAX_WAVEFORM_COUNT, + }); + }, []); + + // When moving between conversations - stop audio + React.useEffect(() => { + return () => { + audio.pause(); + }; + }, [audio, conversationId]); + + React.useEffect(() => { + return () => { + window.log.info('Closing old audio context'); + audioContext.close(); + }; + }, [audioContext]); + + const value = { + audio, + audioContext, + waveformCache, + }; + + return ( + + {children} + + ); +}; diff --git a/ts/components/conversation/Message.stories.tsx b/ts/components/conversation/Message.stories.tsx index 39e838b6da2d..87c591a46198 100644 --- a/ts/components/conversation/Message.stories.tsx +++ b/ts/components/conversation/Message.stories.tsx @@ -3,14 +3,16 @@ import * as React from 'react'; import { isBoolean } from 'lodash'; +import LRU from 'lru-cache'; import { action } from '@storybook/addon-actions'; -import { boolean, number, text } from '@storybook/addon-knobs'; +import { boolean, number, text, select } from '@storybook/addon-knobs'; import { storiesOf } from '@storybook/react'; import { Colors } from '../../types/Colors'; +import { WaveformCache } from '../../types/Audio'; import { EmojiPicker } from '../emoji/EmojiPicker'; -import { Message, Props } from './Message'; +import { Message, Props, AudioAttachmentProps } from './Message'; import { AUDIO_MP3, IMAGE_JPEG, @@ -19,6 +21,7 @@ import { MIMEType, VIDEO_MP4, } from '../../types/MIME'; +import { MessageAudio } from './MessageAudio'; import { setup as setupI18n } from '../../../js/modules/i18n'; import enMessages from '../../../_locales/en/messages.json'; import { pngUrl } from '../../storybook/Fixtures'; @@ -42,10 +45,35 @@ const renderEmojiPicker: Props['renderEmojiPicker'] = ({ /> ); +const MessageAudioContainer: React.FC = props => { + const [activeAudioID, setActiveAudioID] = React.useState( + undefined + ); + const audio = React.useMemo(() => new Audio(), []); + const audioContext = React.useMemo(() => new AudioContext(), []); + const waveformCache: WaveformCache = React.useMemo(() => new LRU(), []); + + return ( + + ); +}; + +const renderAudioAttachment: Props['renderAudioAttachment'] = props => ( + +); + const createProps = (overrideProps: Partial = {}): Props => ({ attachments: overrideProps.attachments, authorId: overrideProps.authorId || 'some-id', - authorColor: overrideProps.authorColor || 'blue', + authorColor: select('authorColor', Colors, Colors[0]), authorAvatarPath: overrideProps.authorAvatarPath, authorTitle: text('authorTitle', overrideProps.authorTitle || ''), bodyRanges: overrideProps.bodyRanges, @@ -89,6 +117,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ reactions: overrideProps.reactions, reactToMessage: action('reactToMessage'), renderEmojiPicker, + renderAudioAttachment, replyToMessage: action('replyToMessage'), retrySend: action('retrySend'), scrollToQuotedMessage: action('scrollToQuotedMessage'), diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 241f9d349a51..d1cbc004e440 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -79,6 +79,17 @@ export type DirectionType = typeof Directions[number]; export const ConversationTypes = ['direct', 'group'] as const; export type ConversationTypesType = typeof ConversationTypes[number]; +export type AudioAttachmentProps = { + id: string; + i18n: LocalizerType; + buttonRef: React.RefObject; + direction: DirectionType; + theme: ThemeType | undefined; + url: string; + withContentAbove: boolean; + withContentBelow: boolean; +}; + export type PropsData = { id: string; conversationId: string; @@ -136,6 +147,8 @@ export type PropsData = { isBlocked: boolean; isMessageRequestAccepted: boolean; bodyRanges?: BodyRangesType; + + renderAudioAttachment: (props: AudioAttachmentProps) => JSX.Element; }; export type PropsHousekeeping = { @@ -219,10 +232,10 @@ const EXPIRED_DELAY = 600; export class Message extends React.PureComponent { public menuTriggerRef: Trigger | undefined; - public audioRef: React.RefObject = React.createRef(); - public focusRef: React.RefObject = React.createRef(); + public audioButtonRef: React.RefObject = React.createRef(); + public reactionsContainerRef: React.RefObject< HTMLDivElement > = React.createRef(); @@ -676,6 +689,8 @@ export class Message extends React.PureComponent { isSticker, text, theme, + + renderAudioAttachment, } = this.props; const { imageBroken } = this.state; @@ -740,24 +755,16 @@ export class Message extends React.PureComponent { ); } if (!firstAttachment.pending && isAudio(attachments)) { - return ( - - ); + return renderAudioAttachment({ + i18n, + buttonRef: this.audioButtonRef, + id, + direction, + theme, + url: firstAttachment.url, + withContentAbove, + withContentBelow, + }); } const { pending, fileName, fileSize, contentType } = firstAttachment; const extension = getExtensionForDisplay({ contentType, fileName }); @@ -2043,17 +2050,13 @@ export class Message extends React.PureComponent { if ( !isAttachmentPending && isAudio(attachments) && - this.audioRef && - this.audioRef.current + this.audioButtonRef && + this.audioButtonRef.current ) { event.preventDefault(); event.stopPropagation(); - if (this.audioRef.current.paused) { - this.audioRef.current.play(); - } else { - this.audioRef.current.pause(); - } + this.audioButtonRef.current.click(); } if (contact && contact.signalAccount) { diff --git a/ts/components/conversation/MessageAudio.tsx b/ts/components/conversation/MessageAudio.tsx new file mode 100644 index 000000000000..1492d28c75af --- /dev/null +++ b/ts/components/conversation/MessageAudio.tsx @@ -0,0 +1,451 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useRef, useEffect, useState } from 'react'; +import classNames from 'classnames'; +import { noop } from 'lodash'; + +import { assert } from '../../util/assert'; +import { LocalizerType } from '../../types/Util'; +import { WaveformCache } from '../../types/Audio'; + +export type Props = { + direction?: 'incoming' | 'outgoing'; + id: string; + i18n: LocalizerType; + url: string; + withContentAbove: boolean; + withContentBelow: boolean; + + // See: GlobalAudioContext.tsx + audio: HTMLAudioElement; + audioContext: AudioContext; + waveformCache: WaveformCache; + + buttonRef: React.RefObject; + + activeAudioID: string | undefined; + setActiveAudioID: (id: string | undefined) => void; +}; + +type LoadAudioOptions = { + audioContext: AudioContext; + waveformCache: WaveformCache; + url: string; +}; + +type LoadAudioResult = { + duration: number; + peaks: ReadonlyArray; +}; + +// Constants + +const CSS_BASE = 'module-message__audio-attachment'; +const PEAK_COUNT = 47; +const BAR_MIN_HEIGHT = 4; +const BAR_MAX_HEIGHT = 20; + +// Increments for keyboard audio seek (in seconds) +const SMALL_INCREMENT = 1; +const BIG_INCREMENT = 5; + +// Utils + +const timeToText = (time: number): string => { + const hours = Math.floor(time / 3600); + let minutes = Math.floor((time % 3600) / 60).toString(); + let seconds = Math.floor(time % 60).toString(); + + if (hours !== 0 && minutes.length < 2) { + minutes = `0${minutes}`; + } + + if (seconds.length < 2) { + seconds = `0${seconds}`; + } + + return hours ? `${hours}:${minutes}:${seconds}` : `${minutes}:${seconds}`; +}; + +/** + * Load audio from `url`, decode PCM data, and compute RMS peaks for displaying + * the waveform. + * + * The results are cached in the `waveformCache` which is shared across + * messages in the conversation and provided by GlobalAudioContext. + */ +// TODO(indutny): move this to GlobalAudioContext and limit the concurrency. +// see DESKTOP-1267 +async function loadAudio(options: LoadAudioOptions): Promise { + const { audioContext, waveformCache, url } = options; + + const existing = waveformCache.get(url); + if (existing) { + window.log.info('MessageAudio: waveform cache hit', url); + return Promise.resolve(existing); + } + + window.log.info('MessageAudio: waveform cache miss', url); + + // Load and decode `url` into a raw PCM + const response = await fetch(url); + const raw = await response.arrayBuffer(); + + const data = await audioContext.decodeAudioData(raw); + + // Compute RMS peaks + const peaks = new Array(PEAK_COUNT).fill(0); + const norms = new Array(PEAK_COUNT).fill(0); + + const samplesPerPeak = data.length / peaks.length; + for ( + let channelNum = 0; + channelNum < data.numberOfChannels; + channelNum += 1 + ) { + const channel = data.getChannelData(channelNum); + + for (let sample = 0; sample < channel.length; sample += 1) { + const i = Math.floor(sample / samplesPerPeak); + peaks[i] += channel[sample] ** 2; + norms[i] += 1; + } + } + + // Average + let max = 1e-23; + for (let i = 0; i < peaks.length; i += 1) { + peaks[i] = Math.sqrt(peaks[i] / Math.max(1, norms[i])); + max = Math.max(max, peaks[i]); + } + + // Normalize + for (let i = 0; i < peaks.length; i += 1) { + peaks[i] /= max; + } + + const result = { peaks, duration: data.duration }; + waveformCache.set(url, result); + return result; +} + +/** + * Display message audio attachment along with its waveform, duration, and + * toggle Play/Pause button. + * + * The waveform is computed off the renderer thread by AudioContext, but it is + * still quite expensive, so we cache it in the `waveformCache` LRU cache. + * + * A global audio player is used for playback and access is managed by the + * `activeAudioID` property. Whenever `activeAudioID` property is equal to `id` + * the instance of the `MessageAudio` assumes the ownership of the `Audio` + * instance and fully manages it. + */ +export const MessageAudio: React.FC = (props: Props) => { + const { + i18n, + id, + direction, + url, + withContentAbove, + withContentBelow, + + buttonRef, + + audio, + audioContext, + waveformCache, + + activeAudioID, + setActiveAudioID, + } = props; + + assert(audio !== null, 'GlobalAudioContext always provides audio'); + + const isActive = activeAudioID === id; + + const waveformRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(isActive && !audio.paused); + const [currentTime, setCurrentTime] = useState( + isActive ? audio.currentTime : 0 + ); + + // NOTE: Avoid division by zero + const [duration, setDuration] = useState(1e-23); + + const [isLoading, setIsLoading] = useState(true); + const [peaks, setPeaks] = useState>( + new Array(PEAK_COUNT).fill(0) + ); + + // This effect loads audio file and computes its RMS peak for dispalying the + // waveform. + useEffect(() => { + if (!isLoading) { + return noop; + } + + let canceled = false; + + (async () => { + try { + const { peaks: newPeaks, duration: newDuration } = await loadAudio({ + audioContext, + waveformCache, + url, + }); + if (canceled) { + return; + } + setPeaks(newPeaks); + setDuration(Math.max(newDuration, 1e-23)); + } catch (err) { + window.log.error('MessageAudio: loadAudio error', err); + } finally { + if (!canceled) { + setIsLoading(false); + } + } + })(); + + return () => { + canceled = true; + }; + }, [url, isLoading, setPeaks, setDuration, audioContext, waveformCache]); + + // This effect attaches/detaches event listeners to the global