Added an app updated modal when the app has been updated

This commit is contained in:
WolverinDEV 2020-08-22 21:20:25 +02:00
parent b0aa0c4c1a
commit 4557479e09
12 changed files with 665 additions and 8 deletions

View file

@ -1,9 +1,6 @@
import {LaterPromise} from "tc-shared/utils/LaterPromise";
import {ErrorCode} from "./ErrorCode";
/* legacy */
export const ErrorID = ErrorCode;
export class CommandResult {
success: boolean;
id: number;

View file

@ -44,6 +44,9 @@ import "./connection/ConnectionBase";
import {ConnectRequestData} from "tc-shared/ipc/ConnectHandler";
import "./video-viewer/Controller";
import "./update/UpdaterWeb";
import {checkForUpdatedApp} from "tc-shared/update";
declare global {
interface Window {
open_connected_question: () => Promise<boolean>;
@ -299,7 +302,7 @@ function main() {
});
server_connections.set_active_connection(server_connections.all_connections()[0]);
checkForUpdatedApp();
/*
(window as any).test_upload = (message?: string) => {

View file

@ -347,5 +347,5 @@ loader.register_task(Stage.LOADED, {
});
},
priority: -2
})
});
*/

View file

@ -0,0 +1,24 @@
import {spawnReactModal} from "tc-shared/ui/react-elements/Modal";
import {InternalModal} from "tc-shared/ui/react-elements/internal-modal/Controller";
import * as React from "react";
import {WhatsNew} from "tc-shared/ui/modal/whats-new/Renderer";
import {Translatable} from "tc-shared/ui/react-elements/i18n";
import {ChangeLog} from "tc-shared/update/ChangeLog";
export function spawnUpdatedModal(changes: { changesUI?: ChangeLog, changesClient?: ChangeLog }) {
const modal = spawnReactModal(class extends InternalModal {
constructor() {
super();
}
renderBody(): React.ReactElement {
return <WhatsNew changesUI={changes.changesUI} changesClient={changes.changesClient} />;
}
title(): string | React.ReactElement<Translatable> {
return <Translatable>We've updated the client for you</Translatable>;
}
});
modal.show();
}

View file

@ -0,0 +1,268 @@
@import "../../../../css/static/mixin";
@import "../../../../css/static/properties";
.container {
width: 57em;
min-width: 20em;
flex-shrink: 1;
flex-grow: 1;
padding: 1em;
display: flex;
flex-direction: column;
justify-content: stretch;
@include user-select(none);
.info {
display: flex;
flex-direction: row;
justify-content: center;
.logo {
flex-shrink: 0;
flex-grow: 0;
width: 15em;
height: 15em;
display: block;
margin-top: 1em;
margin-right: 2em;
align-self: center;
img {
max-width: 100%;
max-height: 100%;
}
}
.text {
display: flex;
flex-direction: column;
justify-content: center;
h1 {
all: unset;
display: block;
font-weight: bold;
margin-bottom: 0;
font-size: 1.8em;
}
h2 {
all: unset;
display: block;
font-weight: bold;
font-size: 1.5em;
margin-bottom: .3em;
}
.subtitleShort {
display: none;
}
.subtitleLong {
display: block;
}
p {
all: unset;
margin-bottom: 1em;
}
}
}
.changes {
display: flex;
flex-direction: column;
justify-content: stretch;
.header {
display: flex;
flex-direction: row;
justify-content: space-between;
position: relative;
margin-top: 1.5em;
padding-bottom: .5em;
padding-right: 1em;
padding-left: 1em;
&:after {
content: "";
display: block;
height: 2px;
position: absolute;
left: 0;
right: 0;
bottom: 0;
@include transition($button_hover_animation_time ease-in-out);
}
.left, .right {
font-size: 1.4em;
cursor: pointer;
@include transition($button_hover_animation_time ease-in-out);
&.hidden {
opacity: 0;
pointer-events: none;
}
&:hover {
color: #b6c4d6;
}
}
&.selectedLeft {
.left {
color: #245184;
}
&:after {
background: linear-gradient(to right, #245184 15em, #999999 60%);
}
}
&.selectedRight {
.right {
color: #245184;
}
&:after {
background: linear-gradient(to left, #245184 15em, #999999 60%);
}
}
}
.body {
margin-top: .5em;
height: 20em;
background: #28292b;
border-radius: 3px;
position: relative;
font-family: consolas, monospace;
.changeList {
flex-shrink: 1;
flex-grow: 1;
display: flex;
flex-direction: column;
justify-content: flex-start;
padding: .4em .8em .8em;
min-height: 1em;
height: 20em;
overflow-x: hidden;
@include chat-scrollbar-vertical();
@include user-select(text);
> ul > li {
margin-top: 1em;
&:first-child {
margin-top: 0;
}
}
ul {
all: unset;
display: block;
margin-left: 1em;
ul {
list-style-type: none;
li:before {
content: '-';
margin-left: -1em;
margin-right: .5em;
}
}
}
code {
background: hsl(0, 0%, 11%);
border-radius: 2px;
color: hsl(0, 0%, 50%);
padding: 0 .25em;
margin-left: -.25em;
margin-right: -.25em;
}
}
&.hidden {
display: none;
}
.containerBrowse {
position: absolute;
color: #245184;
cursor: pointer;
top: 0;
right: .5em;
padding: 1em .5em .5em;
border-radius: 3px;
background-color: #28292bcc;
@include transition($button_hover_animation_time ease-in-out);
&:hover {
background-color: #28292b;
color: #b6c4d6;
}
a[href]:visited {
color: inherit;
}
}
}
}
}
@media all and (max-width: 40em) {
.container .logo {
display: none!important;
}
.container .text {
.subtitleShort {
display: block!important;
}
.subtitleLong {
display: none!important;;
}
p br {
display: none;
}
}
}

View file

@ -0,0 +1,141 @@
import * as React from "react";
import * as dompurify from "dompurify";
import {useState} from "react";
import {ChangeLog, ChangeLogEntry, ChangeSet} from "tc-shared/update/ChangeLog";
import {Translatable, VariadicTranslatable} from "tc-shared/ui/react-elements/i18n";
import {guid} from "tc-shared/crypto/uid";
const { Remarkable } = require("remarkable");
const cssStyle = require("./Renderer.scss");
const mdRenderer = new Remarkable();
const brElementUuid = guid();
export interface DisplayableChangeList extends ChangeLog {
title: React.ReactElement<Translatable>;
url: string;
}
mdRenderer.renderer.rules.link_open = function (tokens, idx, options /*, env */) {
const title = tokens[idx].title ? (`title="${tokens[idx].title}"`) : '';
const target = options.linkTarget ? (`target="${options.linkTarget}"`) : '';
let href = `<a href="${tokens[idx].href}" ${title} ${target} ></a>`;
href = dompurify.sanitize(href);
if(href.substr(-4) !== "</a>")
return "<-- invalid link open... -->";
return href.substr(0, href.length - 4);
};
const ChangeSetRenderer = (props: { set: ChangeSet }) => {
return (
<React.Fragment>
{props.set.title ? <li key={"title"}>{props.set.title}</li> : undefined}
<ul>
{props.set.changes.map((change, index) => typeof change === "string" ? <li key={index} dangerouslySetInnerHTML={{
__html: mdRenderer.renderInline(change.replace(/\n/g, brElementUuid)).replace(new RegExp(brElementUuid, "g"), "<br />")
}} /> : <ChangeSetRenderer set={change} key={index} />)}
</ul>
</React.Fragment>
);
};
const ChangeLogEntryRenderer = React.memo((props: { entry: ChangeLogEntry }) => (
<li>
<b>{props.entry.timestamp}</b>
<ChangeSetRenderer set={props.entry} />
</li>
));
const DisplayableChangeListRenderer = (props: { list: DisplayableChangeList | undefined, visible: boolean }) => (
<div className={cssStyle.body + " " + (!props.visible ? cssStyle.hidden : "")}>
<div className={cssStyle.changeList}>
<ul>
{props.list?.changes.map((value, index) => <ChangeLogEntryRenderer entry={value} key={index} />)}
</ul>
</div>
<div className={cssStyle.containerBrowse}>
<a href={props.list?.url} target={"_blank"}><Translatable>Open full Change Log</Translatable></a>
</div>
</div>
);
const ChangeListRenderer = (props: { left?: DisplayableChangeList, right?: DisplayableChangeList, defaultSelected: "right" | "left" | "none" }) => {
const [ selected, setSelected ] = useState<"left" | "right" | "none">(props.defaultSelected);
return (
<div className={cssStyle.changes}>
<div className={cssStyle.header + " " + (selected === "left" ? cssStyle.selectedLeft : selected === "right" ? cssStyle.selectedRight : "")}>
<div className={cssStyle.left + " " + (props.left ? "" : cssStyle.hidden)} onClick={() => setSelected("left")}>
<a>{props.left?.title}</a>
</div>
<div className={cssStyle.right + " " + (props.right ? "" : cssStyle.hidden)} onClick={() => setSelected("right")}>
<a>{props.right?.title}</a>
</div>
</div>
<DisplayableChangeListRenderer list={props.left} visible={selected === "left"} />
<DisplayableChangeListRenderer list={props.right} visible={selected === "right"} />
</div>
)
};
export const WhatsNew = (props: { changesUI?: ChangeLog, changesClient?: ChangeLog }) => {
let subtitleLong, infoText;
let changesUI = props.changesUI ? Object.assign({
title: <Translatable>UI Change Log</Translatable>,
url: "https://github.com/TeaSpeak/TeaWeb/blob/master/ChangeLog.md"
}, props.changesUI) : undefined;
let changesClient = props.changesClient ? Object.assign({
title: <Translatable>Client Change Log</Translatable>,
url: "https://github.com/TeaSpeak/TeaClient/blob/master/ChangeLog.txt"
}, props.changesClient) : undefined;
let versionUIDate = props.changesUI?.currentVersion, versionNativeDate = props.changesClient?.currentVersion;
if(__build.target === "web") {
subtitleLong = <Translatable key={"sub-web"}>We've successfully updated the web client for you.</Translatable>;
infoText = <VariadicTranslatable key={"info-web"} text={"The web client has been updated to the version from {}."}>{versionUIDate}</VariadicTranslatable>;
} else if(props.changesUI && props.changesClient) {
subtitleLong = <Translatable key={"sub-native-client-ui"}>We've successfully updated the native client and its UI for you.</Translatable>;
infoText = (
<React.Fragment key={"info-native-client-ui"}>
<VariadicTranslatable text={"The native client has been updated to the version from {}."}>{versionNativeDate}</VariadicTranslatable>
<VariadicTranslatable text={"Its UI has been updated to the version {}."}>{versionUIDate}</VariadicTranslatable>
</React.Fragment>
);
} else if(props.changesClient) {
subtitleLong = <Translatable key={"sub-native-client"}>We've successfully updated the native client for you.</Translatable>;
infoText = <VariadicTranslatable key={"info-native-client"} text={"The native client has been updated to the version {}."}>{versionNativeDate}</VariadicTranslatable>;
} else if(props.changesUI) {
subtitleLong = <Translatable key={"sub-native-ui"}>We've successfully updated the native clients UI for you.</Translatable>;
infoText = <VariadicTranslatable key={"info-native-ui"} text={"The native clients UI has been updated to the version from 18.08.2020."}>{versionUIDate}</VariadicTranslatable>;
}
const changes = [ changesUI, changesClient ].filter(e => !!e);
return (
<div className={cssStyle.container}>
<div className={cssStyle.info}>
<div className={cssStyle.logo}>
<img alt={tr("TeaSpeak logo")} src="img/teaspeak_cup_animated.png" />
</div>
<div className={cssStyle.text}>
<h1><Translatable>Welcome back!</Translatable></h1>
<h2 className={cssStyle.subtitleLong}>{subtitleLong}</h2>
<h2 className={cssStyle.subtitleShort}><Translatable>The client has been updated.</Translatable></h2>
<p>
<Translatable>While you've been away resting, we did some work.</Translatable> <br />
{infoText} <br />
<Translatable>A list of changes, bugfixes and new features can be found bellow.</Translatable>
</p>
<a><Translatable>Enjoy!</Translatable></a>
</div>
</div>
<ChangeListRenderer
defaultSelected={"right"}
right={changes[0]}
left={changes[1]}
/>
</div>
);
};

View file

@ -50,7 +50,8 @@ export class Translatable extends React.Component<{
}
}
export const VariadicTranslatable = (props: { text: string, __cacheKey?: string, children?: React.ReactElement[] | React.ReactElement }) => {
export type VariadicTranslatableChild = React.ReactElement | string;
export const VariadicTranslatable = (props: { text: string, __cacheKey?: string, children?: VariadicTranslatableChild[] | VariadicTranslatableChild }) => {
const args = Array.isArray(props.children) ? props.children : [props.children];
const argsUseCount = [...new Array(args.length)].map(() => 0);
@ -63,8 +64,13 @@ export const VariadicTranslatable = (props: { text: string, __cacheKey?: string,
return e;
let element = args[e];
if(argsUseCount[e])
element = cloneElement(element);
if(argsUseCount[e]) {
if(typeof element === "string") {
/* do nothing */
} else {
element = cloneElement(element);
}
}
argsUseCount[e]++;
return <React.Fragment key={"argument-" + e + "-" + argsUseCount[e]}>{element}</React.Fragment>;

View file

@ -0,0 +1,15 @@
export type ChangeSetEntry = ChangeSet | string;
export interface ChangeSet {
title?: string;
changes: ChangeSetEntry[];
}
export interface ChangeLogEntry extends ChangeSet {
timestamp: string;
}
export interface ChangeLog {
changes: ChangeLogEntry[],
currentVersion: string
}

View file

@ -0,0 +1,11 @@
import {ChangeLog} from "tc-shared/update/ChangeLog";
export interface Updater {
getChangeLog() : ChangeLog;
getLastUsedVersion() : string;
getCurrentVersion() : string;
/* update the last used version to the current version */
updateUsedVersion();
}

View file

@ -0,0 +1,130 @@
import {ChangeLog, ChangeSetEntry} from "tc-shared/update/ChangeLog";
import * as loader from "tc-loader";
import {Stage} from "tc-loader";
import {setUIUpdater} from "tc-shared/update/index";
import {Updater} from "tc-shared/update/Updater";
const ChangeLogContents: string = require("../../../ChangeLog.md");
const EntryRegex = /^\* \*\*([0-9]{2})\.([0-9]{2})\.([0-9]{2})\*\*$/m;
function parseChangeLogEntry(lines: string[], index: number) : { entries: ChangeSetEntry[], index: number } {
const entryDepth = lines[index].indexOf("-");
if(entryDepth === -1) {
throw "missing entry depth for line " + index;
}
let entries = [] as ChangeSetEntry[];
let currentEntry;
while(index < lines.length && !lines[index].match(EntryRegex)) {
let trimmed = lines[index].trim();
if(trimmed.length === 0) {
index++;
continue;
}
if(trimmed[0] === '-') {
const depth = lines[index].indexOf('-');
if(depth > entryDepth) {
if(typeof currentEntry === "undefined")
throw "missing change child entries parent at line " + index;
const result = parseChangeLogEntry(lines, index);
entries.push({
changes: result.entries,
title: currentEntry
});
index = result.index;
} else if(depth < entryDepth) {
/* we're done with our block */
break;
} else {
/* new entry */
if(typeof currentEntry === "string")
entries.push(currentEntry);
currentEntry = trimmed.substr(1).trim();
}
} else {
if(typeof currentEntry === "undefined")
throw "this should never happen!";
currentEntry += "\n" + trimmed;
}
index++;
}
if(typeof currentEntry === "string")
entries.push(currentEntry);
return {
index: index,
entries: entries
};
}
function parseUIChangeLog() : ChangeLog {
let result: ChangeLog = {
currentVersion: "unknown",
changes: []
}
const lines = ChangeLogContents.split("\n");
let index = 0;
while(index < lines.length && !lines[index].match(EntryRegex))
index++;
while(index < lines.length) {
const [ _, day, month, year ] = lines[index].match(EntryRegex);
const entry = parseChangeLogEntry(lines, index + 1);
result.changes.push({
timestamp: day + "." + month + "." + year,
changes: entry.entries
});
index = entry.index;
}
return result;
}
const kLastUsedVersionKey = "updater-used-version-web";
class WebUpdater implements Updater {
private readonly changeLog: ChangeLog;
private readonly currentVersion: string;
constructor() {
this.changeLog = parseUIChangeLog();
const currentBuildTimestamp = new Date(__build.timestamp * 1000);
this.currentVersion = ("00" + currentBuildTimestamp.getUTCDate()).substr(-2) + "." +
("00" + currentBuildTimestamp.getUTCMonth()).substr(-2) + "." +
currentBuildTimestamp.getUTCFullYear().toString().substr(2);
}
getChangeLog(): ChangeLog {
return this.changeLog;
}
getCurrentVersion(): string {
return this.currentVersion;
}
getLastUsedVersion(): string {
return localStorage.getItem(kLastUsedVersionKey) || "08.08.20";
}
updateUsedVersion() {
localStorage.setItem(kLastUsedVersionKey, this.getCurrentVersion());
}
}
loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
name: "web updater init",
function: async () => {
setUIUpdater(new WebUpdater());
},
priority: 50
});

55
shared/js/update/index.ts Normal file
View file

@ -0,0 +1,55 @@
import {Updater} from "./Updater";
import {ChangeLog} from "tc-shared/update/ChangeLog";
import {spawnUpdatedModal} from "tc-shared/ui/modal/whats-new/Controller";
let updaterUi: Updater;
let updaterNative: Updater;
export function setUIUpdater(updater: Updater) {
if(typeof updaterUi !== "undefined") {
throw tr("An UI updater has already been registered");
}
updaterUi = updater;
}
export function setNativeUpdater(updater: Updater) {
if(typeof updaterNative !== "undefined") {
throw tr("An native updater has already been registered");
}
updaterNative = updater;
}
function getChangedChangeLog(updater: Updater) : ChangeLog | undefined {
if(updater.getCurrentVersion() === updater.getLastUsedVersion())
return undefined;
let changes = {
changes: [],
currentVersion: updater.getCurrentVersion()
} as ChangeLog;
let usedVersion = updater.getLastUsedVersion();
for(const change of updater.getChangeLog().changes) {
if(change.timestamp === usedVersion)
break;
changes.changes.push(change);
}
return changes.changes.length > 0 ? changes : undefined;
}
export function checkForUpdatedApp() {
let changesUI = updaterUi ? getChangedChangeLog(updaterUi) : undefined;
let changesNative = updaterNative ? getChangedChangeLog(updaterNative) : undefined;
if(changesUI !== undefined || changesNative !== undefined) {
spawnUpdatedModal({
changesUI: changesUI,
changesClient: changesNative
});
updaterUi?.updateUsedVersion();
updaterNative?.updateUsedVersion();
}
}

View file

@ -226,6 +226,13 @@ export const config = async (target: "web" | "client"): Promise<Configuration> =
{
test: /\.svg$/,
loader: 'svg-inline-loader'
},
{
test: /ChangeLog\.md$/i,
loader: "raw-loader",
options: {
esModule: false
}
}
],
},