diff --git a/src/assets/stylesheets/link.scss b/src/assets/stylesheets/link.scss index accfb2be29a363c1022e558a31a9993d6acf92b8..f06d0c81e200942f2539f032f97d3c294258a492 100644 --- a/src/assets/stylesheets/link.scss +++ b/src/assets/stylesheets/link.scss @@ -74,6 +74,7 @@ a { :local(.entry-footer-image) { width: 200px; margin: 12px; + margin-top: 24px; } @@ -128,26 +129,30 @@ a { background-color: $darker-grey; } -:local(.keypad-zero-button) { - grid-column: 2; -} - :local(.keypad-button):disabled { color: $light-grey; border: 6px $dark-grey solid; } -:local(.keypad-backspace) , :local(.keypad-backspace):disabled , :local(.keypad-backspace):active { +:local(.keypad-backspace) , :local(.keypad-backspace):disabled , :local(.keypad-backspace):active, :local(.keypad-toggle-mode), :local(.keypad-toggle-mode):disabled, :local(.keypad-toggle-mode):active { border: none; } +:local(.keypad-backspace) { + grid-column: 3; +} + :local(.keypad-backspace):active { background-color: transparent; color: $light-grey; } -:local(.entered-digits) { - font-face: monospace; +:local(.keypad-toggle-mode) { + font-size: 1.0em; +} + +:local(.entered) { + font-family: monospace; height: 100px; width: 300px; text-align: center; @@ -157,11 +162,11 @@ a { justify-content: center; } -:local(.digit) { +:local(.char) { margin: 8px; } -:local(.digit-input) { +:local(.char-input) { outline-style: none; appearance:textfield; -moz-appearance:textfield; @@ -176,7 +181,32 @@ a { text-align: center; } -:local(.digit-input::placeholder) { +:local(.char-input::placeholder) { letter-spacing: 0; } +:local(.headset-icon) { + width: 64px; + height: 64px; + background-color: white; + border-radius: 32px; + margin-bottom: 8px; + padding-right: 2px; + cursor: pointer; +} + +:local(.link-headset-footer-link) { + display: flex; + align-items: center; + + img { + width: 32px; + height: 32px; + background-color: white; + border-radius: 32px; + margin-bottom: 8px; + margin-right: 16px; + padding-right: 2px; + cursor: pointer; + } +} diff --git a/src/assets/translations.data.json b/src/assets/translations.data.json index 9ae5532c31f373ab397fbb23a58ffa7c0d78ab68..0a21762f7a72b4ca2b582475bd6c3e53d1019822 100644 --- a/src/assets/translations.data.json +++ b/src/assets/translations.data.json @@ -74,9 +74,9 @@ "home.have_entry_code": "have a link code?", "mailing_list.privacy_label": "I'm okay with Mozilla handling my info as explained in", "mailing_list.privacy_link": "this Privacy Notice", - "link.link_page_header": "Enter your code:", - "link.dont_have_a_code": "Don't have a code?", - "link.create_a_room": "Create a Room", + "link.link_page_header_entry": "Enter your code:", + "link.link_page_header_headset": "Enter headset link code:", + "link.linking_a_headset": "Linking a Headset?", "link.try_again": "We couldn't find that code. Please try again.", "help.report_issue": "Report an Issue", "scene.logo_tagline": "A new way to get together", diff --git a/src/link.js b/src/link.js index 908f7f64d4fadd8f906b09a7874e8e882c93b81d..401fe54d9b8b9bd91df1c2140257710502a23add 100644 --- a/src/link.js +++ b/src/link.js @@ -3,7 +3,18 @@ import React from "react"; import ReactDOM from "react-dom"; import registerTelemetry from "./telemetry"; import LinkRoot from "./react-components/link-root"; +import LinkChannel from "./utils/link-channel"; +import { connectToReticulum } from "./utils/phoenix-utils"; +import Store from "./storage/store"; registerTelemetry(); -ReactDOM.render(<LinkRoot />, document.getElementById("link-root")); +const socket = connectToReticulum(); +const store = new Store(); +store.init(); + +const linkChannel = new LinkChannel(store); + +linkChannel.setSocket(socket); + +ReactDOM.render(<LinkRoot store={store} linkChannel={linkChannel} />, document.getElementById("link-root")); diff --git a/src/react-components/link-root.js b/src/react-components/link-root.js index f9aab014bb2f0ba5e6779a4c1020d34aa8416f96..fb1b40c3d5af85fa4a34fc4ffc910f1d697a292d 100644 --- a/src/react-components/link-root.js +++ b/src/react-components/link-root.js @@ -7,19 +7,24 @@ import { lang, messages } from "../utils/i18n"; import classNames from "classnames"; import styles from "../assets/stylesheets/link.scss"; import { disableiOSZoom } from "../utils/disable-ios-zoom"; +import LinkDialogHeader from "../assets/images/link_dialog_header.svg"; const MAX_DIGITS = 6; +const MAX_LETTERS = 4; addLocaleData([...en]); disableiOSZoom(); class LinkRoot extends Component { static propTypes = { - intl: PropTypes.object + intl: PropTypes.object, + store: PropTypes.object, + linkChannel: PropTypes.object }; state = { - enteredDigits: "", + entered: "", + isAlphaMode: false, failedAtLeastOnce: false }; @@ -33,44 +38,97 @@ class LinkRoot extends Component { handleKeyDown = e => { // Number keys 0-9 - if (e.keyCode < 48 || e.keyCode > 57) { + if ((e.keyCode < 48 || e.keyCode > 57) && !this.state.isAlphaMode) { + return; + } + + // Alpha keys A-I + if ((e.keyCode < 65 || e.keyCode > 73) && this.state.isAlphaMode) { return; } e.preventDefault(); e.stopPropagation(); - this.addDigit(e.keyCode - 48); + if (this.state.isAlphaMode) { + this.addToEntry("IHGFEDCBA"[73 - e.keyCode]); + } else { + this.addToEntry(e.keyCode - 48); + } + }; + + maxAllowedChars = () => { + return this.state.isAlphaMode ? MAX_LETTERS : MAX_DIGITS; }; - addDigit = digit => { - if (this.state.enteredDigits.length >= MAX_DIGITS) return; - const newDigits = `${this.state.enteredDigits}${digit}`; + addToEntry = ch => { + if (this.state.entered.length >= this.maxAllowedChars()) return; + const newChars = `${this.state.entered}${ch}`; - if (newDigits.length === MAX_DIGITS) { - this.attemptLink(newDigits); + if (newChars.length === this.maxAllowedChars()) { + this.attemptLookup(newChars); } - this.setState({ enteredDigits: newDigits }); + this.setState({ entered: newChars }); }; - removeDigit = () => { - const enteredDigits = this.state.enteredDigits; - if (enteredDigits.length === 0) return; - this.setState({ enteredDigits: enteredDigits.substring(0, enteredDigits.length - 1) }); + removeChar = () => { + const entered = this.state.entered; + if (entered.length === 0) return; + this.setState({ entered: entered.substring(0, entered.length - 1) }); }; attemptLink = async code => { + this.props.linkChannel + .attemptLink(code) + .then(response => { + // If there is a profile from the linked device, copy it over if we don't have one yet. + if (response.profile) { + const { hasChangedName } = this.props.store.state.activity; + + if (!hasChangedName) { + this.props.store.update({ activity: { hasChangedName: true }, profile: response.profile }); + } + } + + if (response.path) { + window.location.href = response.path; + } + }) + .catch(e => { + this.setState({ failedAtLeastOnce: true, enteredDigits: "" }); + + if (!(e instanceof Error && (e.message === "in_use" || e.message === "failed"))) { + throw e; + } + }); + }; + + attemptEntry = async code => { const url = "https://hub.link/" + code; const res = await fetch(url); if (res.status >= 400) { - this.setState({ failedAtLeastOnce: true, enteredDigits: "" }); + this.setState({ failedAtLeastOnce: true, entered: "" }); } else { document.location = url; } }; + attemptLookup = async code => { + if (this.state.isAlphaMode) { + // Headset link code + this.attemptLink(code); + } else { + // Room entry code + this.attemptEntry(code); + } + }; + + toggleMode = () => { + this.setState({ isAlphaMode: !this.state.isAlphaMode, entered: "" }); + }; + render() { // Note we use type "tel" for the input due to https://bugzilla.mozilla.org/show_bug.cgi?id=1005603 @@ -78,7 +136,7 @@ class LinkRoot extends Component { <IntlProvider locale={lang} messages={messages}> <div className={styles.link}> <div className={styles.linkContents}> - {this.state.enteredDigits.length === MAX_DIGITS && ( + {this.state.entered.length === this.maxAllowedChars() && ( <div className={classNames("loading-panel", styles.codeLoadingPanel)}> <div className="loader-wrap"> <div className="loader"> @@ -90,71 +148,92 @@ class LinkRoot extends Component { <div className={styles.enteredContents}> <div className={styles.header}> - <FormattedMessage id={this.state.failedAtLeastOnce ? "link.try_again" : "link.link_page_header"} /> + <FormattedMessage + id={ + this.state.failedAtLeastOnce + ? "link.try_again" + : "link.link_page_header_" + (!this.state.isAlphaMode ? "entry" : "headset") + } + /> </div> - <div className={styles.enteredDigits}> + <div className={styles.entered}> <input - className={styles.digitInput} - type="tel" - pattern="[0-9]*" - value={this.state.enteredDigits} + className={styles.charInput} + type={this.state.isAlphaMode ? "text" : "tel"} + pattern="[0-9A-I]*" + value={this.state.entered} onChange={ev => { - this.setState({ enteredDigits: ev.target.value }); + this.setState({ entered: ev.target.value }); }} - placeholder="- - - - - -" + placeholder={this.state.isAlphaMode ? "- - - -" : "- - - - - -"} /> </div> <div className={styles.enteredFooter}> - <span> - <FormattedMessage id="link.dont_have_a_code" /> - </span>{" "} - <span> - <a href="/"> - <FormattedMessage id="link.create_a_room" /> - </a> - </span> + {!this.state.isAlphaMode && ( + <img onClick={() => this.toggleMode()} src={LinkDialogHeader} className={styles.headsetIcon} /> + )} + {!this.state.isAlphaMode && ( + <span> + <a href="#" onClick={() => this.toggleMode()}> + <FormattedMessage id="link.linking_a_headset" /> + </a> + </span> + )} <img className={styles.entryFooterImage} src="../assets/images/logo.svg" /> </div> </div> <div className={styles.keypad}> - {[1, 2, 3, 4, 5, 6, 7, 8, 9].map((d, i) => ( + {(this.state.isAlphaMode + ? ["A", "B", "C", "D", "E", "F", "G", "H", "I"] + : [1, 2, 3, 4, 5, 6, 7, 8, 9] + ).map((d, i) => ( <button - disabled={this.state.enteredDigits.length === MAX_DIGITS} - key={`digit_${i}`} + disabled={this.state.entered.length === this.maxAllowedChars()} + key={`char_${i}`} className={styles.keypadButton} - onClick={() => this.addDigit(d)} + onClick={() => this.addToEntry(d)} > {d} </button> ))} <button - disabled={this.state.enteredDigits.length === MAX_DIGITS} - className={classNames(styles.keypadButton, styles.keypadZeroButton)} - onClick={() => this.addDigit(0)} + className={classNames(styles.keypadButton, styles.keypadToggleMode)} + onClick={() => this.toggleMode()} > - 0 + {this.state.isAlphaMode ? "123" : "ABC"} </button> + {!this.state.isAlphaMode && ( + <button + disabled={this.state.entered.length === this.maxAllowedChars()} + className={classNames(styles.keypadButton, styles.keypadZeroButton)} + onClick={() => this.addToEntry(0)} + > + 0 + </button> + )} <button - disabled={this.state.enteredDigits.length === 0 || this.state.enteredDigits.length === MAX_DIGITS} + disabled={this.state.entered.length === 0 || this.state.entered.length === this.maxAllowedChars()} className={classNames(styles.keypadButton, styles.keypadBackspace)} - onClick={() => this.removeDigit()} + onClick={() => this.removeChar()} > ⌫ </button> </div> <div className={styles.footer}> - <span> - <FormattedMessage id="link.dont_have_a_code" /> - </span>{" "} - <span> - <a href="/"> - <FormattedMessage id="link.create_a_room" /> - </a> - </span> + {!this.state.isAlphaMode && ( + <div className={styles.linkHeadsetFooterLink}> + <img onClick={() => this.toggleMode()} src={LinkDialogHeader} className={styles.headsetIcon} /> + <span> + <a href="#" onClick={() => this.toggleMode()}> + <FormattedMessage id="link.linking_a_headset" /> + </a> + </span> + </div> + )} <img className={styles.footerImage} src="../assets/images/logo.svg" alt="Logo" /> </div> </div>