Move UI components to ui/ directory

This commit is contained in:
hensm
2019-04-09 01:11:24 +01:00
parent a6ebb4fd22
commit 56ec766d86
22 changed files with 19 additions and 15 deletions

View File

@@ -0,0 +1,357 @@
/* tslint:disable:max-line-length */
"use strict";
import React, { Component } from "react";
import semver from "semver";
import { getNextEllipsis
, getWindowCenteredProps } from "../../lib/utils";
import { BridgeInfo } from "../../lib/getBridgeInfo";
const _ = browser.i18n.getMessage;
const ENDPOINT_URL = "https://api.github.com/repos/hensm/fx_cast/releases/latest";
async function downloadApp (info: any, platform: string) {
const download = browser.downloads.download({
filename: info[platform].name
, url: info[platform].url
});
}
interface BridgeDownloadsProps {
info: any;
}
const BridgeDownloads = (props: BridgeDownloadsProps) => (
<div className="bridge-downloads">
<button className="bridge-downloads__download
bridge-downloads__win"
disabled
onClick={ () => downloadApp(props.info, "win") }>
Windows
</button>
<button className="bridge-downloads__download
bridge-downloads__mac"
onClick={ () => downloadApp(props.info, "mac") }>
macOS
</button>
<div className="bridge-downloads__linux">
<button className="bridge-downloads__download"
onClick={ () => downloadApp(props.info, "deb") }>
Linux (deb)
</button>
<button className="bridge-downloads__download"
onClick={ () => downloadApp(props.info, "rpm") }>
Linux (rpm)
</button>
</div>
</div>
);
interface BridgeStatsProps {
info: BridgeInfo;
}
const BridgeStats = (props: BridgeStatsProps) => (
<table className="bridge__stats">
<tr>
<th>{ _("optionsBridgeStatsName") }</th>
<td>{ props.info.name }</td>
</tr>
<tr>
<th>{ _("optionsBridgeStatsVersion") }</th>
<td>{ props.info.version }</td>
</tr>
<tr>
<th>{ _("optionsBridgeStatsExpectedVersion") }</th>
<td>{ props.info.expectedVersion }</td>
</tr>
<tr>
<th>{ _("optionsBridgeStatsCompatibility") }</th>
<td>
{ props.info.isVersionCompatible
? props.info.isVersionExact
? _("optionsBridgeCompatible")
: _("optionsBridgeLikelyCompatible")
: _("optionsBridgeIncompatible") }
</td>
</tr>
<tr>
<th>{ _("optionsBridgeStatsRecommendedAction") }</th>
<td>
{ // If older
props.info.isVersionOlder
? _("optionsBridgeOlderAction")
// else if newer
: props.info.isVersionNewer
? _("optionsBridgeNewerAction")
// else
: _("optionsBridgeNoAction")
}
</td>
</tr>
</table>
);
interface BridgeProps {
info: BridgeInfo;
platform: string;
loading: boolean;
}
interface BridgeState {
isCheckingUpdates: boolean;
isUpdateAvailable: boolean;
wasErrorCheckingUpdates: boolean;
checkUpdatesEllipsis: string;
updateStatus: string;
packageType: string;
}
export default class Bridge extends Component<BridgeProps, BridgeState> {
private updateData: any;
private updateStatusTimeout: number;
constructor (props: BridgeProps) {
super(props);
this.state = {
isCheckingUpdates: false
, isUpdateAvailable: false
, wasErrorCheckingUpdates: false
, checkUpdatesEllipsis: "..."
, updateStatus: null
, packageType: null
};
this.onCheckUpdates = this.onCheckUpdates.bind(this);
this.onCheckUpdatesResponse = this.onCheckUpdatesResponse.bind(this);
this.onCheckUpdatesError = this.onCheckUpdatesError.bind(this);
this.onUpdate = this.onUpdate.bind(this);
this.onPackageTypeChange = this.onPackageTypeChange.bind(this);
}
public render () {
return (
<div className="bridge">
{ this.props.loading
? ( <div className="bridge__loading">
{ _("optionsBridgeLoading") }
<progress></progress>
</div> )
: this.renderStatus() }
{ !this.props.loading &&
<div className="bridge__update-info">
{ this.state.isUpdateAvailable
? ( <div className="bridge__update">
<p className="bridge__update-label">
{ _("optionsBridgeUpdateAvailable") }
</p>
<div className="bridge__update-options">
{ this.props.platform === "linux" &&
<select className="bridge__update-package-type"
onChange={ this.onPackageTypeChange }
value={ this.state.packageType }>
<option value="" disabled selected>
{ _("optionsBridgeUpdatePackageTypeSelect") }
</option>
<option value="deb">
{ _("optionsBridgeUpdatePackageTypeDeb") }
</option>
<option value="rpm">
{ _("optionsBridgeUpdatePackageTypeRpm") }
</option>
</select> }
<button className="bridge__update-start"
onClick={ this.onUpdate }
disabled={ this.props.platform === "linux"
&& !this.state.packageType }>
{ _("optionsBridgeUpdate") }
</button>
</div>
</div> )
: ( <button className="bridge__update-check"
disabled={ this.state.isCheckingUpdates }
onClick={ this.onCheckUpdates }>
{ this.state.isCheckingUpdates
? _("optionsBridgeUpdateChecking"
, getNextEllipsis(this.state.checkUpdatesEllipsis))
: _("optionsBridgeUpdateCheck") }
</button> )}
<div className="bridge--update-status">
{ this.state.updateStatus && !this.state.isUpdateAvailable
&& this.state.updateStatus }
</div>
</div> }
</div>
);
}
private renderStatus () {
const infoClasses = `bridge__info ${this.props.info
? "bridge__info--found"
: "bridge__info--not-found"}`;
let statusIcon: string;
let statusTitle: string;
let statusText: string;
if (!this.props.info) {
statusIcon = "assets/icons8-cancel-120.png";
statusTitle = _("optionsBridgeNotFoundStatusTitle");
statusText = _("optionsBridgeNotFoundStatusText");
} else {
if (this.props.info.isVersionCompatible) {
statusIcon = "assets/icons8-ok-120.png";
statusTitle = _("optionsBridgeFoundStatusTitle");
} else {
statusIcon = "assets/icons8-warn-120.png";
statusTitle = _("optionsBridgeIssueStatusTitle");
}
}
return (
<div className={infoClasses}>
<div className="bridge__status">
<img className="bridge__status-icon"
width="60" height="60"
src={ statusIcon } />
<h2 className="bridge__status-title">
{ statusTitle }
</h2>
{ statusText &&
<p className="bridge__status-text">
{ statusText }
</p> }
</div>
{ this.props.info &&
<BridgeStats info={ this.props.info }/> }
</div>
);
}
private onCheckUpdates () {
this.setState({
isCheckingUpdates: true
});
const timeout = window.setInterval(() => {
this.setState(state => ({
checkUpdatesEllipsis: getNextEllipsis(
state.checkUpdatesEllipsis)
}));
}, 500);
fetch(ENDPOINT_URL)
.then(res => {
window.clearTimeout(timeout);
return res.json();
})
.then(this.onCheckUpdatesResponse)
.catch(this.onCheckUpdatesError);
}
private showUpdateStatus () {
if (this.updateStatusTimeout) {
window.clearTimeout(this.updateStatusTimeout);
}
this.updateStatusTimeout = window.setTimeout(() => {
this.setState({
updateStatus: null
});
}, 1500);
}
private async onUpdate () {
// Current window to base centered position on
const win = await browser.windows.getCurrent();
const centeredProps = getWindowCenteredProps(win, 400, 150);
const updaterPopup = await browser.windows.create({
url: "../updater/index.html"
, type: "popup"
, ...centeredProps
});
// Size/position not set correctly on creation (bug?)
await browser.windows.update(updaterPopup.id, {
...centeredProps
});
browser.runtime.onConnect.addListener(port => {
if (port.name === "updater") {
const asset = this.updateData.assets.find((currentAsset: any) => {
const fileExtension = currentAsset.name.match(/.*\.(.*)$/).pop();
const currentPlatform = (this.props.platform === "linux")
? this.state.packageType
: this.props.platform;
switch (fileExtension) {
case "exe": return "win" === currentPlatform;
case "pkg": return "mac" === currentPlatform;
case "deb": return "deb" === currentPlatform;
case "rpm": return "rpm" === currentPlatform;
}
});
port.postMessage({
subject: "updater:/updateData"
, data: asset
});
port.onDisconnect.addListener(() => {
browser.windows.remove(updaterPopup.id);
});
}
});
}
private async onCheckUpdatesResponse (res: any) {
const isUpdateAvailable = !this.props.info || semver.lt(
this.props.info.version, res.tag_name);
if (isUpdateAvailable) {
this.updateData = res;
}
this.setState({
isCheckingUpdates: false
, isUpdateAvailable
, updateStatus: !isUpdateAvailable
? _("optionsBridgeUpdateStatusNoUpdates")
: null
});
this.showUpdateStatus();
}
private onCheckUpdatesError () {
this.setState({
isCheckingUpdates: false
, wasErrorCheckingUpdates: true
, updateStatus: _("optionsBridgeUpdateStatusError")
});
this.showUpdateStatus();
}
private onPackageTypeChange (ev: React.ChangeEvent<HTMLSelectElement>) {
this.setState({
packageType: ev.target.value
});
}
}

View File

@@ -0,0 +1,184 @@
/* tslint:disable:max-line-length */
"use strict";
import React, { Component } from "react";
import EditableListItem from "./EditableListItem";
const _ = browser.i18n.getMessage;
interface EditableListProps {
data: string[];
itemPattern: RegExp;
onChange (data: string[]): void;
itemPatternError (err?: string): string;
}
interface EditableListState {
addingNewItem: boolean;
rawView: boolean;
rawViewValue: string;
}
export default class EditableList extends Component<
EditableListProps, EditableListState> {
private rawViewTextArea: HTMLTextAreaElement;
constructor (props: EditableListProps) {
super(props);
this.state = {
addingNewItem: false
, rawView: false
, rawViewValue: ""
};
this.handleItemRemove = this.handleItemRemove.bind(this);
this.handleItemEdit = this.handleItemEdit.bind(this);
this.handleSwitchView = this.handleSwitchView.bind(this);
this.handleSaveRaw = this.handleSaveRaw.bind(this);
this.handleRawViewTextAreaChange = this.handleRawViewTextAreaChange.bind(this);
this.handleAddItem = this.handleAddItem.bind(this);
this.handleNewItemRemove = this.handleNewItemRemove.bind(this);
this.handleNewItemEdit = this.handleNewItemEdit.bind(this);
}
public render () {
return (
<div className="editable-list">
<div className="editable-list__view-actions">
{ this.state.rawView &&
<button className="editable-list__save-raw-button"
onClick={ this.handleSaveRaw }
type="button">
{ _("optionsUserAgentWhitelistSaveRaw") }
</button> }
<button className="editable-list__view-button"
onClick={ this.handleSwitchView }
type="button">
{ this.state.rawView
? _("optionsUserAgentWhitelistBasicView")
: _("optionsUserAgentWhitelistRawView") }
</button>
</div>
<hr />
{ this.state.rawView
? (
<textarea className="editable-list__raw-view"
rows={ this.props.data.length}
value={ this.state.rawViewValue}
onChange={ this.handleRawViewTextAreaChange }
ref={ el => { this.rawViewTextArea = el; }}>
</textarea>
) : (
<ul className="editable-list__items">
{ this.props.data.map((item, i) =>
<EditableListItem text={ item }
itemPattern={ this.props.itemPattern }
itemPatternError={ this.props.itemPatternError }
onRemove={ this.handleItemRemove }
onEdit={ this.handleItemEdit }
key={ i } /> )}
{ this.state.addingNewItem &&
<EditableListItem text=""
itemPattern={ this.props.itemPattern }
itemPatternError={ this.props.itemPatternError }
onRemove={ this.handleNewItemRemove }
onEdit={ this.handleNewItemEdit }
editing={ true } /> }
<div className="editable-list__item editable-list__item-actions">
<button className="editable-list__add-button"
onClick={ this.handleAddItem }
type="button">
{ _("optionsUserAgentWhitelistAddItem") }
</button>
</div>
</ul>
)}
</div>
);
}
private handleItemRemove (item: string) {
const newItems = new Set(this.props.data);
newItems.delete(item);
this.props.onChange([...newItems]);
}
private handleItemEdit (item: string, newValue: string) {
this.props.onChange(this.props.data.map(
currentItem => currentItem === item
? newValue
: currentItem));
}
private handleSwitchView () {
this.setState(currentState => {
if (currentState.rawView) {
return {
rawView: false
, rawViewValue: ""
};
}
return {
rawView: true
, rawViewValue: this.props.data.join("\n")
};
});
}
private handleSaveRaw () {
this.setState(currentState => {
const newItems = currentState.rawViewValue.split("\n")
.filter(item => item !== "");
if ("itemPattern" in this.props) {
for (const item of newItems) {
if (!this.props.itemPattern.test(item)) {
this.rawViewTextArea.setCustomValidity(
this.props.itemPatternError(item));
return;
}
}
this.rawViewTextArea.setCustomValidity("");
}
this.props.onChange(newItems);
});
}
private handleRawViewTextAreaChange (ev: React.ChangeEvent<HTMLTextAreaElement>) {
if (this.rawViewTextArea.scrollHeight > this.rawViewTextArea.clientHeight) {
this.rawViewTextArea.style.height = `${this.rawViewTextArea.scrollHeight}px`;
}
this.setState({
rawViewValue: ev.target.value
});
}
private handleAddItem () {
this.setState({
addingNewItem: true
});
}
private handleNewItemRemove () {
this.setState({
addingNewItem: false
});
}
private handleNewItemEdit (item: string, newItem: string) {
this.setState({
addingNewItem: false
}, () => {
this.props.onChange([ ...this.props.data, newItem ]);
});
}
}

View File

@@ -0,0 +1,124 @@
/* tslint:disable:max-line-length */
"use strict";
import React, { Component } from "react";
const _ = browser.i18n.getMessage;
interface EditableListItemProps {
text: string;
itemPattern: RegExp;
editing?: boolean;
itemPatternError (err?: string): string;
onRemove (item: string): void;
onEdit (item: string, newValue: string): void;
}
interface EditableListItemState {
editing: boolean;
editValue: string;
}
export default class EditableListItem extends Component<
EditableListItemProps, EditableListItemState> {
private input: HTMLInputElement;
constructor (props: EditableListItemProps) {
super(props);
this.state = {
editing: this.props.editing || false
, editValue: ""
};
this.handleRemove = this.handleRemove.bind(this);
this.handleEditBegin = this.handleEditBegin.bind(this);
this.handleEditEnd = this.handleEditEnd.bind(this);
this.handleInputChange = this.handleInputChange.bind(this);
this.handleInputKeyPress = this.handleInputKeyPress.bind(this);
}
public render () {
const selected = this.state.editing
? "editable-list__item--selected" : "";
return (
<li className={`editable-list__item ${selected}`}>
<div className="editable-list__title"
onDoubleClick={ this.handleEditBegin }>
{ this.state.editing
? <input className="editable-list__edit-field"
type="text"
ref={ input => this.input = input }
value={ this.state.editValue }
onBlur={ this.handleEditEnd }
onChange={ this.handleInputChange }
onKeyPress={ this.handleInputKeyPress }/>
: this.props.text }
</div>
<button onClick={ this.handleEditBegin }
type="button">
{ _("optionsUserAgentWhitelistEditItem") }
</button>
<button onClick={ this.handleRemove }
type="button">
{ _("optionsUserAgentWhitelistRemoveItem") }
</button>
</li>
);
}
private stopEditing (input: HTMLInputElement) {
if (this.props.editing
&& !this.props.itemPattern.test(this.state.editValue)) {
input.setCustomValidity(this.props.itemPatternError());
}
if (!input.validity.valid) {
return;
}
this.props.onEdit(this.props.text, this.state.editValue);
this.setState({
editing: false
, editValue: ""
});
}
private handleRemove () {
this.props.onRemove(this.props.text);
}
private handleEditBegin () {
this.setState({
editing: true
, editValue: this.props.text
}, () => {
this.input.focus();
});
}
private handleEditEnd (ev: React.FocusEvent<HTMLInputElement>) {
this.stopEditing(ev.target);
}
private handleInputChange (ev: React.ChangeEvent<HTMLInputElement>) {
this.setState({
editValue: ev.target.value
});
if (!this.props.itemPattern.test(ev.target.value)) {
ev.target.setCustomValidity(this.props.itemPatternError());
} else {
ev.target.setCustomValidity("");
}
}
private handleInputKeyPress (ev: React.KeyboardEvent<HTMLInputElement>) {
if (ev.key === "Enter") {
this.stopEditing(ev.target as HTMLInputElement);
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="styles/index.css">
<script src="bundle.js" defer></script>
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -0,0 +1,362 @@
/* tslint:disable:max-line-length */
"use strict";
import React, { Component } from "react";
import ReactDOM from "react-dom";
import defaultOptions, { Options } from "../../defaultOptions";
import Bridge from "./Bridge";
import EditableList from "./EditableList";
import getBridgeInfo, { BridgeInfo } from "../../lib/getBridgeInfo";
import { REMOTE_MATCH_PATTERN_REGEX } from "../../lib/utils";
const _ = browser.i18n.getMessage;
// macOS styles
browser.runtime.getPlatformInfo()
.then(platformInfo => {
if (platformInfo.os === "mac") {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "styles/mac.css";
document.head.appendChild(link);
}
});
function getInputValue (input: HTMLInputElement) {
switch (input.type) {
case "checkbox":
return input.checked;
case "number":
return parseFloat(input.value);
default:
return input.value;
}
}
interface OptionsAppState {
hasLoaded: boolean;
options: Options;
bridgeInfo: BridgeInfo;
platform: string;
bridgeLoading: boolean;
isFormValid: boolean;
hasSaved: boolean;
}
class OptionsApp extends Component<{}, OptionsAppState> {
private form: HTMLFormElement;
constructor (props: {}) {
super(props);
this.state = {
hasLoaded: false
, options: null
, bridgeInfo: null
, platform: null
, bridgeLoading: true
, isFormValid: true
, hasSaved: false
};
this.handleReset = this.handleReset.bind(this);
this.handleFormSubmit = this.handleFormSubmit.bind(this);
this.handleFormChange = this.handleFormChange.bind(this);
this.handleInputChange = this.handleInputChange.bind(this);
this.handleWhitelistChange = this.handleWhitelistChange.bind(this);
this.getWhitelistItemPatternError
= this.getWhitelistItemPatternError.bind(this);
}
public async componentDidMount () {
const { options } = await browser.storage.sync.get("options");
this.setState({
hasLoaded: true
, options
});
const bridgeInfo = await getBridgeInfo();
const { os } = await browser.runtime.getPlatformInfo();
this.setState({
bridgeInfo
, platform: os
, bridgeLoading: false
});
}
public render () {
if (!this.state.hasLoaded) {
return;
}
return (
<div>
<Bridge info={ this.state.bridgeInfo }
platform={ this.state.platform }
loading={ this.state.bridgeLoading } />
<form id="form" ref={ form => { this.form = form; }}
onSubmit={ this.handleFormSubmit }
onChange={ this.handleFormChange }>
<fieldset className="category">
<legend className="category__name">
<h2>{ _("optionsMediaCategoryName") }</h2>
</legend>
<p className="category__description">
{ _("optionsMediaCategoryDescription") }
</p>
<label className="option option--inline">
<input name="mediaEnabled"
type="checkbox"
checked={ this.state.options.mediaEnabled }
onChange={ this.handleInputChange } />
<div className="option__label">
{ _("optionsMediaEnabled") }
</div>
</label>
<label className="option option--inline">
<input name="mediaSyncElement"
type="checkbox"
checked={ this.state.options.mediaSyncElement }
onChange={ this.handleInputChange } />
<div className="option__label">
{ _("optionsMediaSyncElement") }
</div>
</label>
<label className="option option--inline">
<input name="mediaStopOnUnload"
type="checkbox"
checked={ this.state.options.mediaStopOnUnload }
onChange={ this.handleInputChange } />
<div className="option__label">
{ _("optionsMediaStopOnUnload") }
</div>
</label>
<fieldset className="category"
disabled={ !this.state.options.mediaEnabled }>
<legend className="category__name">
<h2>{ _("optionsLocalMediaCategoryName") }</h2>
</legend>
<p className="category__description">
{ _("optionsLocalMediaCategoryDescription") }
</p>
<label className="option option--inline">
<input name="localMediaEnabled"
type="checkbox"
checked={ this.state.options.localMediaEnabled }
onChange={ this.handleInputChange } />
<div className="option__label">
{ _("optionsLocalMediaEnabled") }
</div>
</label>
<label className="option">
<div className="option__label">
{ _("optionsLocalMediaServerPort") }
</div>
<input name="localMediaServerPort"
type="number"
required
min="1025"
max="65535"
value={ this.state.options.localMediaServerPort }
onChange={ this.handleInputChange } />
</label>
</fieldset>
</fieldset>
<fieldset className="category">
<legend className="category__name">
<h2>{ _("optionsMirroringCategoryName") }</h2>
</legend>
<p className="category__description">
{ _("optionsMirroringCategoryDescription") }
</p>
<label className="option option--inline">
<input name="mirroringEnabled"
type="checkbox"
checked={ this.state.options.mirroringEnabled }
onChange={ this.handleInputChange } />
<div className="option__label">
{ _("optionsMirroringEnabled") }
</div>
</label>
<label className="option">
<div className="option__label">
{ _("optionsMirroringAppId") }
</div>
<input name="mirroringAppId"
type="text"
required
value={ this.state.options.mirroringAppId }
onChange={ this.handleInputChange } />
</label>
</fieldset>
<fieldset className="category">
<legend className="category__name">
<h2>{ _("optionsUserAgentWhitelistCategoryName") }</h2>
</legend>
<p className="category__description">
{ _("optionsUserAgentWhitelistCategoryDescription") }
</p>
<label className="option option--inline">
<input name="userAgentWhitelistEnabled"
type="checkbox"
checked={ this.state.options.userAgentWhitelistEnabled }
onChange={ this.handleInputChange } />
<div className="option__label">
{ _("optionsUserAgentWhitelistEnabled") }
</div>
</label>
<div className="option">
<div className="option__label">
{ _("optionsUserAgentWhitelistContent") }
</div>
<EditableList data={ this.state.options.userAgentWhitelist }
onChange={ this.handleWhitelistChange }
itemPattern={ REMOTE_MATCH_PATTERN_REGEX }
itemPatternError={ this.getWhitelistItemPatternError } />
</div>
</fieldset>
<div id="buttons">
<div id="status-line">
{ this.state.hasSaved && _("optionsSaved") }
</div>
<button onClick={ this.handleReset }
type="button">
{ _("optionsReset") }
</button>
<button type="submit"
default
disabled={ !this.state.isFormValid }>
{ _("optionsSave") }
</button>
</div>
</form>
</div>
);
}
/**
* Set stored option values to current state
*/
private setStorage () {
return browser.storage.sync.set({
options: this.state.options
});
}
private handleReset () {
this.setState({
options: { ...defaultOptions }
});
}
private async handleFormSubmit (ev: React.FormEvent<HTMLFormElement>) {
ev.preventDefault();
this.form.reportValidity();
try {
const { options: oldOptions }
= await browser.storage.sync.get("options");
await this.setStorage();
const { options } = await browser.storage.sync.get("options");
const alteredOptions = [];
for (const [ key, val ] of Object.entries(options)) {
const oldVal = oldOptions[key];
if (oldVal !== val) {
alteredOptions.push(key);
}
}
this.setState({
hasSaved: true
}, () => {
window.setTimeout(() => {
this.setState({
hasSaved: false
});
}, 1000);
});
// Send update message / event
browser.runtime.sendMessage({
subject: "optionsUpdated"
, data: { alteredOptions }
});
} catch (err) {
console.error("Failed to save options");
}
}
private handleFormChange (ev: React.FormEvent<HTMLFormElement>) {
ev.preventDefault();
this.setState({
isFormValid: this.form.checkValidity()
});
}
private handleInputChange (ev: React.ChangeEvent<HTMLInputElement>) {
const { target } = ev;
this.setState(({ options }) => {
options[target.name as keyof Options] = getInputValue(target);
return { options };
});
}
private handleWhitelistChange (whitelist: string[]) {
this.setState(({ options }) => {
options.userAgentWhitelist = whitelist;
return { options };
});
}
private getWhitelistItemPatternError (info: string): string {
return _("optionsUserAgentWhitelistInvalidMatchPattern", info);
}
private async updateBridgeInfo () {
this.setState({
bridgeLoading: true
});
const bridgeInfo = await getBridgeInfo();
this.setState({
bridgeInfo
, bridgeLoading: false
});
}
}
ReactDOM.render(
<OptionsApp />
, document.querySelector("#root"));

View File

@@ -0,0 +1,299 @@
:root {
--border-color: rgb(225, 225, 225);
--secondary-color: rgb(125, 125, 125);
}
*:invalid {
box-shadow: 0 0 1.5px 1px red;
}
#form {
display: flex;
flex-direction: column;
}
#buttons {
display: flex;
align-items: center;
align-self: flex-end;
margin-block-start: 20px;
}
#buttons > :not(:last-child) {
margin-inline-end: 5px;
}
#status-line {
color: var(--secondary-color);
}
.bridge {
border-bottom: 1px solid var(--border-color);
margin-bottom: 10px;
padding-bottom: 20px;
}
.bridge,
.bridge__loading {
display: flex;
flex-direction: column;
}
.bridge__loading {
align-items: center;
align-self: center;
font-size: 1.25em;
font-weight: 300;
width: 30%;
}
.bridge__loading progress {
margin-top: 5px;
width: 100%;
}
.bridge__info {
display: flex;
padding-inline-start: 25px;
}
.bridge__status {
align-items: center;
display: flex;
flex-basis: min-content;
flex-direction: column;
margin-inline-end: 25px;
}
.bridge__info--not-found {
padding-inline-end: 25px;
}
.bridge__info--found .bridge__status {
border-inline-end: 1px solid var(--border-color);
padding-inline-end: 25px;
}
.bridge__status-title {
margin: initial;
font-weight: 500;
font-size: 1.5em;
white-space: nowrap;
}
.bridge__status-text {
margin: initial;
margin-top: 5px;
font-size: 1.15em;
font-weight: 300;
text-align: center;
}
.bridge__info--not-found .bridge__status {
display: grid;
grid-template-columns: min-content 1fr;
grid-template-rows: min-content min-content;
grid-template-areas:
"status-icon status-title"
"status-icon status-text";
}
.bridge__info--found .bridge__status-icon {
margin-block-end: 5px;
}
.bridge__info--not-found .bridge__status-icon {
grid-area: status-icon;
margin-inline-end: 10px;
}
.bridge__info--not-found .bridge__status-title {
grid-area: status-title;
font-weight: normal;
white-space: normal;
}
.bridge__info--not-found .bridge__status-text {
grid-area: status-text;
margin-top: initial;
}
.bridge__stats {
border-collapse: collapse;
border-spacing: 0;
}
.bridge__stats th {
font-weight: 500;
padding-inline-end: 10px;
text-align: end;
vertical-align: top;
white-space: nowrap;
}
.bridge__update-info {
align-items: center;
display: flex;
margin-top: 30px;
}
.bridge__update-label {
display: inline-block;
margin: initial;
}
.bridge__update-options {
display: inline-flex;
flex-direction: column;
margin-left: 10px;
}
.bridge__update-start {
align-self: flex-end;
margin-top: 5px;
}
.bridge--update-status {
margin-left: 10px;
}
.category {
border: initial;
display: grid;
grid-template-columns: 150px 1fr;
grid-column-gap: 10px;
grid-row-gap: 5px;
margin: initial;
padding: 10px 0;
}
.category:disabled {
color: var(--secondary-color);
}
#form > .category:not(:first-child) {
border-top: 1px solid var(--border-color);
}
.category > .category {
padding: 5px 0;
box-shadow: inset 2px 0 0 0 var(--border-color);
}
.category > .category > .category__name,
.category > .category > .category__description {
margin-inline-start: 16px;
}
.category__name {
float: left;
}
.category__name > h2 {
font-size: 1.15em;
font-weight: 500;
margin: initial;
}
.category__description {
color: var(--secondary-color);
margin-top: initial;
max-width: 60ch;
}
.category__name,
.category__description,
.category .category {
grid-column: span 2;
}
.option {
display: contents;
}
.option--inline {
display: block;
grid-column-start: 2;
}
.option__label {
text-align: right;
display: inline-block;
}
.option > input {
align-self: center;
justify-self: flex-start;
margin-inline-start: initial;
}
.editable-list {
background-color: -moz-field;
border: 1px solid var(--border-color);
color: -moz-fieldtext;
justify-content: end;
padding: 5px;
}
.editable-list__view-actions {
display: flex;
justify-content: end;
}
.editable-list__view-button,
.editable-list__save-raw-button {
}
.editable-list hr {
border: initial;
border-top: 1px solid var(--border-color);
margin: 5px 0;
}
.editable-list__items {
display: flex;
flex-direction: column;
margin: initial;
margin-inline-start: -5px;
padding: initial;
width: calc(100% + 10px);
}
.editable-list__item {
align-items: center;
display: flex;
height: 2em;
padding: 0 5px;
}
.editable-list__item:nth-child(even):not(:last-child) {
background-color: -moz-eventreerow;
}
.editable-list__item:nth-child(odd):not(:last-child) {
background-color: -moz-oddtreerow;
}
.editable-list__item--selected {
background-color: -moz-cellhighlight !important;
color: -moz-cellhighlighttext !important;
}
.editable-list__title {
flex: 1;
}
.editable-list__edit-field {
width: -moz-available;
margin-inline-end: 1em;
}
.editable-list__raw-view {
max-height: 300px;
resize: vertical;
width: 100%;
}
.editable-list__add-button {
align-self: end;
margin-top: 5px;
}

View File

@@ -0,0 +1,24 @@
body {
font: menu;
}
button,
select,
input {
font: inherit;
}
button:not([disabled]):hover:active {
color: -moz-mac-buttonactivetext;
}
button[default]:not([disabled]):not(:-moz-window-inactive) {
color: -moz-mac-defaultbuttontext;
}
button[default]:not(:hover):active {
color: ButtonText;
}
button,
select {
height: 22px;
}

11
ext/src/ui/popup/index.html Executable file
View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="styles/index.css">
<script src="bundle.js" defer></script>
</head>
<body>
<div id="root"></div>
</body>
</html>

240
ext/src/ui/popup/index.tsx Executable file
View File

@@ -0,0 +1,240 @@
/* tslint:disable:max-line-length */
"use strict";
import React, { Component } from "react";
import ReactDOM from "react-dom";
import { getNextEllipsis } from "../../lib/utils";
import * as types from "../../types";
const _ = browser.i18n.getMessage;
// macOS styles
browser.runtime.getPlatformInfo()
.then(platformInfo => {
if (platformInfo.os === "mac") {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "styles/mac.css";
document.head.appendChild(link);
}
});
const winWidth = 350;
let winHeight = 200;
let frameHeight: number;
let frameWidth: number;
interface PopupAppState {
receivers: types.Receiver[];
selectedMedia: string;
isLoading: boolean;
}
class PopupApp extends Component<{}, PopupAppState> {
private port: browser.runtime.Port;
private win: browser.windows.Window;
constructor (props: {}) {
super(props);
this.state = {
receivers: []
, selectedMedia: "app"
, isLoading: false
};
// Store window ref
browser.windows.getCurrent().then(win => {
this.win = win;
frameHeight = win.height - window.innerHeight;
frameWidth = win.width - window.innerWidth;
});
this.onSelectChange = this.onSelectChange.bind(this);
this.onCast = this.onCast.bind(this);
}
public componentDidMount () {
const backgroundPort = browser.runtime.connect({
name: "popup"
});
backgroundPort.onMessage.addListener((message: types.Message) => {
if (message.subject === "popup:/assignShim") {
this.setPort(message.data.tabId
, message.data.frameId);
}
});
}
public render () {
const shareMedia =
this.state.selectedMedia === "tab"
|| this.state.selectedMedia === "screen";
return (
<div>
<div className="media-select">
Cast
<select value={ this.state.selectedMedia }
onChange={ this.onSelectChange }
className="media-select-dropdown">
<option value="app" disabled={ shareMedia }>this site's app</option>
<option value="tab" disabled={ !shareMedia }>Tab</option>
<option value="screen" disabled={ !shareMedia }>Screen</option>
</select>
to:
</div>
<ul className="receivers">
{ this.state.receivers.map((receiver, i) => {
return (
<Receiver receiver={ receiver }
onCast={ this.onCast }
isLoading={ this.state.isLoading }
key={ i }/>
);
})}
</ul>
</div>
);
}
private async setPort (shimTabId: number, shimFrameId: number) {
if (this.port) {
this.port.disconnect();
}
this.port = browser.tabs.connect(shimTabId, {
name: "popup"
, frameId: shimFrameId
});
this.port.postMessage({
subject: "shim:/popupReady"
});
this.port.onMessage.addListener((message: types.Message) => {
switch (message.subject) {
case "popup:/populateReceiverList": {
this.setState({
receivers: message.data.receivers
, selectedMedia: message.data.selectedMedia
}, () => {
// Get height of content without window decoration
winHeight = document.body.clientHeight + frameHeight;
// Adjust height to fit content
browser.windows.update(this.win.id, {
height: winHeight
});
});
break;
}
case "popup:/close": {
window.close();
break;
}
}
});
}
private onCast (receiver: types.Receiver) {
this.setState({
isLoading: true
});
this.port.postMessage({
subject: "shim:/selectReceiver"
, data: {
receiver
, selectedMedia: this.state.selectedMedia
, a: 5
}
});
}
private onSelectChange (ev: React.ChangeEvent<HTMLSelectElement>) {
this.setState({
selectedMedia: ev.target.value
});
}
}
interface ReceiverProps {
receiver: types.Receiver;
isLoading: boolean;
onCast (receiver: types.Receiver): void;
}
interface ReceiverState {
ellipsis: string;
isLoading: boolean;
}
class Receiver extends Component<ReceiverProps, ReceiverState> {
constructor (props: ReceiverProps) {
super(props);
this.state = {
isLoading: false
, ellipsis: ""
};
this.handleCast = this.handleCast.bind(this);
}
public render () {
return (
<li className="receiver">
<div className="receiver-name">
{ this.props.receiver.friendlyName }
</div>
<div className="receiver-address">
{ `${this.props.receiver.address}:${this.props.receiver.port}` }
</div>
<div className="receiver-status">
{ this.props.receiver.currentApp &&
`- ${this.props.receiver.currentApp}` }
</div>
<button className="receiver-connect"
onClick={ this.handleCast }
disabled={this.props.isLoading}>
{ this.state.isLoading
? _("popupCastingButtonLabel") +
(this.state.isLoading
? this.state.ellipsis
: "")
: _("popupCastButtonLabel") }
</button>
</li>
);
}
private handleCast () {
this.props.onCast(this.props.receiver);
this.setState({
isLoading: true
});
setInterval(() => {
this.setState(state => ({
ellipsis: getNextEllipsis(state.ellipsis)
}));
}, 500);
}
}
ReactDOM.render(
<PopupApp />
, document.querySelector("#root"));

View File

@@ -0,0 +1,68 @@
body {
background: -moz-dialog;
color: -moz-dialogtext;
margin: initial;
font: message-box;
}
.media-select {
border-bottom: 1px solid rgba(0, 0, 0, 0.25);
margin: 0 1em;
padding: 0.75em 0;
}
.media-select-dropdown {
display: inline-block;
margin: 0 0.5em;
}
.receivers {
list-style: none;
margin: initial;
padding: initial;
}
.receiver {
column-gap: 0.75em;
display: grid;
flex-direction: column;
grid-template-columns: min-content 1fr min-content;
grid-template-rows: min-content min-content 1fr;
grid-template-areas:
"name name connect"
"address status connect";
justify-content: center;
margin: 0 1em;
padding: 0.75em 0;
position: relative;
}
.receiver:not(:last-child) {
border-bottom: 1px solid rgba(0, 0, 0, 0.25);
}
.receiver-name,
.receiver-address {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.receiver-name {
font-size: 1.1em;
grid-area: name;
}
.receiver-address {
color: GrayText;
grid-area: address;
}
.receiver-status {
grid-area: status;
}
.receiver-connect {
align-self: center;
grid-area: connect;
justify-self: end;
min-width: 100px;
height: 32px;
}

22
ext/src/ui/popup/styles/mac.css Executable file
View File

@@ -0,0 +1,22 @@
body {
background: rgb(236, 236, 236);
font: menu;
}
button,
select {
font: inherit;
}
button:not([disabled]):hover:active {
color: -moz-mac-buttonactivetext;
}
.receiver-address,
.receiver-status {
font: message-box;
}
.receiver-connect {
height: 22px;
}

View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<link rel="stylesheet" href="styles/index.css">
<script src="bundle.js" defer></script>
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -0,0 +1,247 @@
"use strict";
import React, { Component } from "react";
import ReactDOM from "react-dom";
import { getNextEllipsis } from "../../lib/utils";
import { DownloadDelta, Message } from "../../types";
const _ = browser.i18n.getMessage;
// macOS styles
browser.runtime.getPlatformInfo()
.then(platformInfo => {
if (platformInfo.os === "mac") {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "styles/mac.css";
document.head.appendChild(link);
}
});
interface UpdaterProps {
description: string;
additionalDescription: string;
downloadTotal: number;
downloadCurrent: number;
isDownloading: boolean;
onCancel (): void;
onInstall (): void;
}
const Updater = (props: UpdaterProps) => (
<div className="updater">
<div className="updater__description">
{ props.description }
</div>
<div className="updater__additional-description">
{ props.additionalDescription }
</div>
<progress className="updater__progress"
max={ props.downloadTotal }
value={ props.downloadCurrent }>
</progress>
<button className="updater__install"
onClick={ props.onInstall }
disabled={ props.isDownloading }>
{ _("updaterInstall") }
</button>
<button className="updater__cancel"
onClick={ props.onCancel }
disabled={ !props.isDownloading }>
{ _("updaterCancel") }
</button>
</div>
);
interface UpdaterAppState {
hasLoaded: boolean;
isDownloading: boolean;
description: string;
additionalDescription: string;
downloadTotal: number;
downloadCurrent: number;
}
class UpdaterApp extends Component<{}, UpdaterAppState> {
private downloadId: number;
private downloadProgressInterval: number;
private port: browser.runtime.Port;
private frameWidth: number;
private frameHeight: number;
private win: browser.windows.Window;
constructor (props: {}) {
super(props);
this.downloadId = null;
this.downloadProgressInterval = null;
this.onMessage = this.onMessage.bind(this);
this.onDownloadChanged = this.onDownloadChanged.bind(this);
this.updateDownloadProgress = this.updateDownloadProgress.bind(this);
this.onCancel = this.onCancel.bind(this);
this.onInstall = this.onInstall.bind(this);
this.state = {
hasLoaded: false
, isDownloading: true
, description: _("updaterDescriptionDownloading")
, additionalDescription: _("updaterAdditionalDescriptionDownloading")
, downloadTotal: 0
, downloadCurrent: 0
};
}
public async componentDidMount () {
this.port = browser.runtime.connect({
name: "updater"
});
this.port.onMessage.addListener(this.onMessage);
browser.downloads.onChanged.addListener(this.onDownloadChanged);
}
public componentDidUpdate () {
// Size window to content
browser.windows.update(this.win.id, {
width: document.body.clientWidth + this.frameWidth
, height: document.body.clientHeight + this.frameHeight
});
}
public render () {
if (!this.state.hasLoaded) {
return;
}
return (
<Updater description={ this.state.description }
additionalDescription={ this.state.additionalDescription }
downloadTotal={ this.state.downloadTotal }
downloadCurrent={ this.state.downloadCurrent }
isDownloading={ this.state.isDownloading }
onCancel={ this.onCancel }
onInstall={ this.onInstall } />
);
}
private async onMessage (message: Message) {
switch (message.subject) {
case "updater:/updateData": {
// Only run once
if (this.downloadId) {
return;
}
this.win = await browser.windows.getCurrent();
this.frameWidth = this.win.width - window.innerWidth;
this.frameHeight = this.win.height - window.innerHeight;
// Cleanup
if (this.downloadId) {
browser.downloads.cancel(this.downloadId);
}
if (this.downloadProgressInterval) {
window.clearInterval(this.downloadProgressInterval);
}
this.downloadId = await browser.downloads.download({
url: message.data.browser_download_url
, filename: message.data.name
});
this.updateDownloadProgress();
this.downloadProgressInterval = window.setInterval(
this.updateDownloadProgress, 500);
this.setState({
hasLoaded: true
, isDownloading: true
});
break;
}
}
}
private closeWindow () {
window.clearInterval(this.downloadProgressInterval);
browser.downloads.onChanged.removeListener(this.onDownloadChanged);
this.port.onMessage.removeListener(this.onMessage);
this.port.disconnect();
}
private onDownloadChanged (downloadItem: DownloadDelta) {
if (downloadItem.id !== this.downloadId) {
return;
}
if (downloadItem.canResume) {
// Paused
if (downloadItem.canResume.current) {
window.clearInterval(this.downloadProgressInterval);
this.setState({
isDownloading: false
});
// Cancelled
} else {
window.clearInterval(this.downloadProgressInterval);
this.setState({
isDownloading: false
});
}
// Download finished
} else if (downloadItem.state
&& downloadItem.state.current === "complete") {
window.clearInterval(this.downloadProgressInterval);
this.setState({
isDownloading: false
, downloadTotal: 1
, downloadCurrent: 1
, description: _("updaterDescriptionInstallReady")
, additionalDescription:
_("updaterAdditionalDescriptionInstallReady")
});
}
}
private async updateDownloadProgress () {
const [ download ] = await browser.downloads.search({
id: this.downloadId
});
this.setState({
downloadTotal: download.totalBytes
, downloadCurrent: download.bytesReceived
});
}
private async onCancel () {
try {
await browser.downloads.cancel(this.downloadId);
this.closeWindow();
} catch (err) {
// Already cancelled or finished
}
}
private async onInstall () {
try {
await browser.downloads.open(this.downloadId);
this.closeWindow();
} catch (err) {
// Cancelled or not finished
}
}
}
ReactDOM.render(
<UpdaterApp />
, document.querySelector("#root"));

View File

@@ -0,0 +1,38 @@
body {
background: -moz-dialog;
color: -moz-dialogtext;
margin: initial;
font: message-box;
}
.updater {
align-items: center;
display: grid;
gap: 0.75em;
grid-template-rows: min-content min-content 1fr min-content;
grid-template-columns: 1fr min-content min-content;
grid-template-areas:
"description description description"
"additional-description additional-description additional-description"
"progress progress progress"
". cancel install";
padding: 0.75em;
}
.updater__description {
grid-area: description;
}
.updater__additional-description {
font-size: 0.9em;
grid-area: additional-description;
margin-top: -0.75em;
}
.updater__progress {
grid-area: progress;
}
.updater__install {
grid-area: install;
}
.updater__cancel {
grid-area: cancel;
}

View File

@@ -0,0 +1,13 @@
body {
background: rgb(236, 236, 236);
font: menu;
}
button,
select {
font: inherit;
}
button:not([disabled]):hover:active {
color: -moz-mac-buttonactivetext;
}