diff --git a/shared/js/connection/ServerConnectionDeclaration.ts b/shared/js/connection/ServerConnectionDeclaration.ts index 4596ee68..af36b423 100644 --- a/shared/js/connection/ServerConnectionDeclaration.ts +++ b/shared/js/connection/ServerConnectionDeclaration.ts @@ -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; diff --git a/shared/js/main.tsx b/shared/js/main.tsx index 2ad872f1..3cf8e50a 100644 --- a/shared/js/main.tsx +++ b/shared/js/main.tsx @@ -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; @@ -299,7 +302,7 @@ function main() { }); server_connections.set_active_connection(server_connections.all_connections()[0]); - + checkForUpdatedApp(); /* (window as any).test_upload = (message?: string) => { diff --git a/shared/js/ui/modal/settings/Microphone.tsx b/shared/js/ui/modal/settings/Microphone.tsx index b7dd03e9..d0bf25e6 100644 --- a/shared/js/ui/modal/settings/Microphone.tsx +++ b/shared/js/ui/modal/settings/Microphone.tsx @@ -347,5 +347,5 @@ loader.register_task(Stage.LOADED, { }); }, priority: -2 -}) +}); */ \ No newline at end of file diff --git a/shared/js/ui/modal/whats-new/Controller.tsx b/shared/js/ui/modal/whats-new/Controller.tsx new file mode 100644 index 00000000..f7609ea3 --- /dev/null +++ b/shared/js/ui/modal/whats-new/Controller.tsx @@ -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 ; + } + + title(): string | React.ReactElement { + return We've updated the client for you; + } + }); + + modal.show(); +} \ No newline at end of file diff --git a/shared/js/ui/modal/whats-new/Renderer.scss b/shared/js/ui/modal/whats-new/Renderer.scss new file mode 100644 index 00000000..bc323c7e --- /dev/null +++ b/shared/js/ui/modal/whats-new/Renderer.scss @@ -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; + } + } +} \ No newline at end of file diff --git a/shared/js/ui/modal/whats-new/Renderer.tsx b/shared/js/ui/modal/whats-new/Renderer.tsx new file mode 100644 index 00000000..99e21c82 --- /dev/null +++ b/shared/js/ui/modal/whats-new/Renderer.tsx @@ -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; + 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 = ``; + href = dompurify.sanitize(href); + if(href.substr(-4) !== "") + return "<-- invalid link open... -->"; + return href.substr(0, href.length - 4); +}; + +const ChangeSetRenderer = (props: { set: ChangeSet }) => { + return ( + + {props.set.title ?
  • {props.set.title}
  • : undefined} +
      + {props.set.changes.map((change, index) => typeof change === "string" ?
    • ") + }} /> : )} +
    +
    + ); +}; + +const ChangeLogEntryRenderer = React.memo((props: { entry: ChangeLogEntry }) => ( +
  • + {props.entry.timestamp} + +
  • +)); + +const DisplayableChangeListRenderer = (props: { list: DisplayableChangeList | undefined, visible: boolean }) => ( +
    +
    +
      + {props.list?.changes.map((value, index) => )} +
    +
    + +
    +); + +const ChangeListRenderer = (props: { left?: DisplayableChangeList, right?: DisplayableChangeList, defaultSelected: "right" | "left" | "none" }) => { + const [ selected, setSelected ] = useState<"left" | "right" | "none">(props.defaultSelected); + + return ( +
    +
    +
    setSelected("left")}> + {props.left?.title} +
    +
    setSelected("right")}> + {props.right?.title} +
    +
    + + +
    + ) +}; + +export const WhatsNew = (props: { changesUI?: ChangeLog, changesClient?: ChangeLog }) => { + let subtitleLong, infoText; + + let changesUI = props.changesUI ? Object.assign({ + title: UI Change Log, + url: "https://github.com/TeaSpeak/TeaWeb/blob/master/ChangeLog.md" + }, props.changesUI) : undefined; + + let changesClient = props.changesClient ? Object.assign({ + title: Client Change Log, + 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 = We've successfully updated the web client for you.; + infoText = {versionUIDate}; + } else if(props.changesUI && props.changesClient) { + subtitleLong = We've successfully updated the native client and its UI for you.; + infoText = ( + + {versionNativeDate} + {versionUIDate} + + ); + } else if(props.changesClient) { + subtitleLong = We've successfully updated the native client for you.; + infoText = {versionNativeDate}; + } else if(props.changesUI) { + subtitleLong = We've successfully updated the native clients UI for you.; + infoText = {versionUIDate}; + } + + const changes = [ changesUI, changesClient ].filter(e => !!e); + return ( +
    +
    +
    + {tr("TeaSpeak +
    +
    +

    Welcome back!

    +

    {subtitleLong}

    +

    The client has been updated.

    +

    + While you've been away resting, we did some work.
    + {infoText}
    + A list of changes, bugfixes and new features can be found bellow. +

    + Enjoy! +
    +
    + +
    + ); +}; diff --git a/shared/js/ui/react-elements/i18n/index.tsx b/shared/js/ui/react-elements/i18n/index.tsx index db51cd91..fe390ae4 100644 --- a/shared/js/ui/react-elements/i18n/index.tsx +++ b/shared/js/ui/react-elements/i18n/index.tsx @@ -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 {element}; diff --git a/shared/js/update/ChangeLog.ts b/shared/js/update/ChangeLog.ts new file mode 100644 index 00000000..73cb5012 --- /dev/null +++ b/shared/js/update/ChangeLog.ts @@ -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 +} \ No newline at end of file diff --git a/shared/js/update/Updater.ts b/shared/js/update/Updater.ts new file mode 100644 index 00000000..1be8162d --- /dev/null +++ b/shared/js/update/Updater.ts @@ -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(); +} \ No newline at end of file diff --git a/shared/js/update/UpdaterWeb.ts b/shared/js/update/UpdaterWeb.ts new file mode 100644 index 00000000..9f450a1a --- /dev/null +++ b/shared/js/update/UpdaterWeb.ts @@ -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 +}); \ No newline at end of file diff --git a/shared/js/update/index.ts b/shared/js/update/index.ts new file mode 100644 index 00000000..9ecc75e6 --- /dev/null +++ b/shared/js/update/index.ts @@ -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(); + } +} \ No newline at end of file diff --git a/webpack.config.ts b/webpack.config.ts index e8db3f7d..9ee8a9d6 100644 --- a/webpack.config.ts +++ b/webpack.config.ts @@ -226,6 +226,13 @@ export const config = async (target: "web" | "client"): Promise = { test: /\.svg$/, loader: 'svg-inline-loader' + }, + { + test: /ChangeLog\.md$/i, + loader: "raw-loader", + options: { + esModule: false + } } ], },