mirror of
https://github.com/hensm/fx_cast.git
synced 2026-06-09 09:09:58 +00:00
Move UI components to ui/ directory
This commit is contained in:
357
ext/src/ui/options/Bridge.tsx
Normal file
357
ext/src/ui/options/Bridge.tsx
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
184
ext/src/ui/options/EditableList.tsx
Normal file
184
ext/src/ui/options/EditableList.tsx
Normal 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 ]);
|
||||
});
|
||||
}
|
||||
}
|
||||
124
ext/src/ui/options/EditableListItem.tsx
Normal file
124
ext/src/ui/options/EditableListItem.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
ext/src/ui/options/assets/icons8-cancel-120.png
Normal file
BIN
ext/src/ui/options/assets/icons8-cancel-120.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
ext/src/ui/options/assets/icons8-ok-120.png
Normal file
BIN
ext/src/ui/options/assets/icons8-ok-120.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
BIN
ext/src/ui/options/assets/icons8-warn-120.png
Normal file
BIN
ext/src/ui/options/assets/icons8-warn-120.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 KiB |
11
ext/src/ui/options/index.html
Normal file
11
ext/src/ui/options/index.html
Normal 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>
|
||||
362
ext/src/ui/options/index.tsx
Normal file
362
ext/src/ui/options/index.tsx
Normal 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"));
|
||||
299
ext/src/ui/options/styles/index.css
Normal file
299
ext/src/ui/options/styles/index.css
Normal 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;
|
||||
}
|
||||
24
ext/src/ui/options/styles/mac.css
Normal file
24
ext/src/ui/options/styles/mac.css
Normal 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
11
ext/src/ui/popup/index.html
Executable 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
240
ext/src/ui/popup/index.tsx
Executable 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"));
|
||||
68
ext/src/ui/popup/styles/index.css
Executable file
68
ext/src/ui/popup/styles/index.css
Executable 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
22
ext/src/ui/popup/styles/mac.css
Executable 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;
|
||||
}
|
||||
11
ext/src/ui/updater/index.html
Normal file
11
ext/src/ui/updater/index.html
Normal 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>
|
||||
247
ext/src/ui/updater/index.tsx
Normal file
247
ext/src/ui/updater/index.tsx
Normal 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"));
|
||||
38
ext/src/ui/updater/styles/index.css
Executable file
38
ext/src/ui/updater/styles/index.css
Executable 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;
|
||||
}
|
||||
13
ext/src/ui/updater/styles/mac.css
Executable file
13
ext/src/ui/updater/styles/mac.css
Executable 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;
|
||||
}
|
||||
Reference in New Issue
Block a user