diff --git a/css/open.css b/css/open.css index 560f7a32..88f3c7ea 100644 --- a/css/open.css +++ b/css/open.css @@ -50,3 +50,40 @@ limitations under the License. border-bottom: 1px solid var(--grey); padding: 4px 0; } + +.OpeningClientView { + display: flex; + align-items: center; + text-align: center; + flex-direction: column; + margin: 0; + line-height: 150%; +} + +.OpeningClientView .defaultAvatar { + width: 64px; + height: 64px; + background-image: url('../images/chat-icon.svg'); + background-repeat: no-repeat; + background-position: center; + background-size: 85%; +} + +.OpeningClientView .clientIcon { + border-radius: 8px; + background-repeat: no-repeat; + background-size: cover; + width: 60px; + height: 60px; + overflow: hidden; + display: block; + margin-left: 16px; +} + +.OpeningClientView .timeoutOptions { + margin-top: 15px; +} + +.OpeningClientView .spinner { + margin-top: 15px; +} diff --git a/src/Link.js b/src/Link.js index 079d7be4..1f72be6e 100644 --- a/src/Link.js +++ b/src/Link.js @@ -30,6 +30,10 @@ export const IdentifierKind = createEnum( "GroupId", ); +function idToPath(identifier) { + return encodeURIComponent(identifier.substring(1)); +} + function asPrefix(identifierKind) { switch (identifierKind) { case IdentifierKind.RoomId: return "!"; @@ -40,6 +44,16 @@ function asPrefix(identifierKind) { } } +function asPath(identifierKind) { + switch (identifierKind) { + case IdentifierKind.RoomId: return "roomid"; + case IdentifierKind.RoomAlias: return "r"; + case IdentifierKind.GroupId: return null; + case IdentifierKind.UserId: return "u"; + default: throw new Error("invalid id kind " + identifierKind); + } +} + function getWebInstanceMap(queryParams) { const prefix = "web-instance["; const postfix = "]"; @@ -183,4 +197,15 @@ export class Link { return `/${this.identifier}`; } } + + toMatrixUrl() { + const prefix = asPath(this.identifierKind); + if (!prefix) { + // Some matrix.to links aren't valid matrix: links (i.e. groups) + return null; + } + const identifier = idToPath(this.identifier); + const suffix = this.eventId ? `/e/${idToPath(this.eventId)}` : ""; + return `matrix:${prefix}/${identifier}${suffix}`; + } } diff --git a/src/main.js b/src/main.js index 9826dfd5..eb01795b 100644 --- a/src/main.js +++ b/src/main.js @@ -24,6 +24,7 @@ export async function main(container) { const vm = new RootViewModel({ request: xhrRequest, openLink: url => location.href = url, + setTimeout: (f, time) => window.setTimeout(f, time), platforms: guessApplicablePlatforms(navigator.userAgent, navigator.platform), preferences: new Preferences(window.localStorage), origin: location.origin, diff --git a/src/open/AutoOpenViewModel.js b/src/open/AutoOpenViewModel.js new file mode 100644 index 00000000..b96a1c2c --- /dev/null +++ b/src/open/AutoOpenViewModel.js @@ -0,0 +1,80 @@ +/* +Copyright 2020 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import {ViewModel} from "../utils/ViewModel.js"; + +export class AutoOpenViewModel extends ViewModel { + constructor(options) { + super(options); + const {client, link, openLinkVM, proposedPlatform, webPlatform} = options; + this._client = client; + this._link = link; + this._openLinkVM = openLinkVM; + this._proposedPlatform = proposedPlatform; + this._webPlatform = webPlatform; + } + + get name() { + return this._client?.name; + } + + get iconUrl() { + return this._client?.icon; + } + + get openingDefault() { + return !this._client; + } + + get autoRedirect() { + // Only auto-redirect when a preferred client hasn't been set. + return this.openingDefault; + } + + get deepLink() { + return this._client ? + this._client.getDeepLink(this._proposedPlatform, this._link) : + this._link.toMatrixUrl() + } + + get webDeepLink() { + return this._client && this._webPlatform && this._client.getDeepLink(this._webPlatform, this._link); + } + + close() { + this._openLinkVM.closeAutoOpen(); + } + + startSpinner() { + this.trying = true; + this.setTimeout(() => { + if (this.autoRedirect) { + // We're about to be closed so don't + // bother with visual updates. + this.close(); + } else { + this.trying = false; + this.emitChange(); + } + }, 1000); + this.emitChange(); + } + + tryOpenLink() { + this.openLink(this.deepLink); + this.startSpinner(); + } +} diff --git a/src/open/ClientViewModel.js b/src/open/ClientViewModel.js index 39873153..84e2329f 100644 --- a/src/open/ClientViewModel.js +++ b/src/open/ClientViewModel.js @@ -17,14 +17,7 @@ limitations under the License. import {isWebPlatform, isDesktopPlatform, Platform} from "../Platform.js"; import {ViewModel} from "../utils/ViewModel.js"; import {IdentifierKind} from "../Link.js"; - -function getMatchingPlatforms(client, supportedPlatforms) { - const clientPlatforms = client.platforms; - const matchingPlatforms = supportedPlatforms.filter(p => { - return clientPlatforms.includes(p); - }); - return matchingPlatforms; -} +import {getMatchingPlatforms, selectPlatforms} from "./clients/index.js"; export class ClientViewModel extends ViewModel { constructor(options) { @@ -40,10 +33,10 @@ export class ClientViewModel extends ViewModel { _update() { const matchingPlatforms = getMatchingPlatforms(this._client, this.platforms); - this._webPlatform = matchingPlatforms.find(p => isWebPlatform(p)); - this._nativePlatform = matchingPlatforms.find(p => !isWebPlatform(p)); - const preferredPlatform = matchingPlatforms.find(p => p === this.preferences.platform); - this._proposedPlatform = preferredPlatform || this._nativePlatform || this._webPlatform; + const {proposedPlatform, nativePlatform, webPlatform} = selectPlatforms(matchingPlatforms, this.preferences.platform); + this._nativePlatform = nativePlatform; + this._webPlatform = webPlatform; + this._proposedPlatform = proposedPlatform; this.openActions = this._createOpenActions(); this.installActions = this._createInstallActions(); diff --git a/src/open/OpenLinkView.js b/src/open/OpenLinkView.js index c7dfe0fa..4bc366b7 100644 --- a/src/open/OpenLinkView.js +++ b/src/open/OpenLinkView.js @@ -22,14 +22,56 @@ import {ServerConsentView} from "./ServerConsentView.js"; export class OpenLinkView extends TemplateView { render(t, vm) { return t.div({className: "OpenLinkView card"}, [ - t.mapView(vm => vm.previewViewModel, previewVM => previewVM ? - new ShowLinkView(vm) : - new ServerConsentView(vm.serverConsentViewModel) - ), + t.mapView(vm => [ vm.openDefaultViewModel, vm.previewViewModel ], ([openDefaultVM, previewVM]) => { + if (openDefaultVM) { + return new TryingLinkView(openDefaultVM) + } else if (previewVM) { + return new ShowLinkView(vm); + } else { + return new ServerConsentView(vm.serverConsentViewModel); + } + }), ]); } } + +class TryingLinkView extends TemplateView { + render (t, vm) { + const children = [ + vm.iconUrl ? t.img({ className: "clientIcon", src: vm.iconUrl }) : t.div({className: "defaultAvatar"}), + t.h1(vm.name ? `Opening ${vm.name}` : "Trying to open your default client..."), + ] + if (vm.name) { + children.push(t.span(["Select ", t.strong(`"Open ${vm.name}"`), " to launch the app."])); + } + if (vm.autoRedirect) { + children.push("If this doesn't work, you will be redirected shortly."); + } + if (vm.webDeepLink) { + children.push(t.span(["You can also ", t.a({ + href: vm.webDeepLink, + target: "_blank", + rel: "noopener noreferrer", + }, `open ${vm.name} in your browser.`)])); + } + const timeoutOptions = t.span({ className: "timeoutOptions" }, [ + t.strong("Not working? "), + t.a({ href: vm.deepLink, onClick: () => vm.startSpinner() }, "Try again"), + " or ", + t.button({ className: "text", onClick: () => vm.close() }, "select another app.") + ]); + children.push( + t.map(vm => vm.trying, trying => trying ? + t.div({className: "spinner"}) : + timeoutOptions + ), + ); + + return t.div({ className: "OpeningClientView" }, children); + } +} + class ShowLinkView extends TemplateView { render(t, vm) { return t.div([ diff --git a/src/open/OpenLinkViewModel.js b/src/open/OpenLinkViewModel.js index 2b5d2954..c8d65a38 100644 --- a/src/open/OpenLinkViewModel.js +++ b/src/open/OpenLinkViewModel.js @@ -18,9 +18,12 @@ import {ViewModel} from "../utils/ViewModel.js"; import {ClientListViewModel} from "./ClientListViewModel.js"; import {ClientViewModel} from "./ClientViewModel.js"; import {PreviewViewModel} from "../preview/PreviewViewModel.js"; +import {AutoOpenViewModel} from "./AutoOpenViewModel.js"; import {ServerConsentViewModel} from "./ServerConsentViewModel.js"; import {getLabelForLinkKind} from "../Link.js"; import {orderedUnique} from "../utils/unique.js"; +import {getMatchingPlatforms, selectPlatforms} from "./clients/index.js"; +import {Platform} from "../Platform.js"; export class OpenLinkViewModel extends ViewModel { constructor(options) { @@ -28,10 +31,62 @@ export class OpenLinkViewModel extends ViewModel { const {clients, link} = options; this._link = link; this._clients = clients; + this.openDefaultViewModel = null; this.serverConsentViewModel = null; this.previewViewModel = null; this.clientsViewModel = null; this.previewLoading = false; + this.tryingLink = false; + if (!this._tryAutoOpen()) { + this._activeOpen(); + } + } + + _tryAutoOpen() { + const client = this._getPreferredClient(); + let proposedPlatform = null; + let webPlatform = null; + if (client) { + const matchingPlatforms = getMatchingPlatforms(client, this.platforms); + const selectedPlatforms = selectPlatforms(matchingPlatforms, this.preferences.platform); + if (selectedPlatforms.proposedPlatform !== selectedPlatforms.nativePlatform) { + // Do not auto-open web applications + return false; + } + proposedPlatform = selectedPlatforms.proposedPlatform; + webPlatform = selectedPlatforms.webPlatform; + + if (!client.getDeepLink(proposedPlatform, this._link)) { + // Client doesn't support deep links. We can't open it. + return false; + } + } else { + if (this.platforms.includes(Platform.iOS)) { + // Do not try to auto-open links on iOS because of the scary warning. + return false; + } + } + this.openDefaultViewModel = new AutoOpenViewModel(this.childOptions({ + client, + link: this._link, + openLinkVM: this, + proposedPlatform, + webPlatform, + })); + this.openDefaultViewModel.tryOpenLink(); + return true; + } + + closeAutoOpen() { + this.openDefaultViewModel = null; + // If no client was selected, this is a no-op. + // Otherwise, see ClientViewModel.back for some reasons + // why we do this. + this.preferences.setClient(undefined, undefined); + this._activeOpen(); + } + + _activeOpen() { if (this.preferences.homeservers === null) { this._showServerConsent(); } else { @@ -53,11 +108,16 @@ export class OpenLinkViewModel extends ViewModel { this._showLink(); } })); + this.emitChange(); } - async _showLink() { + _getPreferredClient() { const clientId = this.preferences.clientId || this._link.clientId; - const preferredClient = clientId ? this._clients.find(c => c.id === clientId) : null; + return clientId ? this._clients.find(c => c.id === clientId) : null; + } + + async _showLink() { + const preferredClient = this._getPreferredClient(); this.clientsViewModel = new ClientListViewModel(this.childOptions({ clients: this._clients, link: this._link, diff --git a/src/open/clients/index.js b/src/open/clients/index.js index 44acee29..46a5fea4 100644 --- a/src/open/clients/index.js +++ b/src/open/clients/index.js @@ -21,6 +21,25 @@ import {Fractal} from "./Fractal.js"; import {Quaternion} from "./Quaternion.js"; import {Tensor} from "./Tensor.js"; import {Fluffychat} from "./Fluffychat.js"; +import {isWebPlatform} from "../../Platform.js" + +export function getMatchingPlatforms(client, supportedPlatforms) { + const clientPlatforms = client.platforms; + const matchingPlatforms = supportedPlatforms.filter(p => { + return clientPlatforms.includes(p); + }); + return matchingPlatforms; +} + +export function selectPlatforms(matchingPlatforms, userPreferredPlatform) { + const webPlatform = matchingPlatforms.find(p => isWebPlatform(p)); + const nativePlatform = matchingPlatforms.find(p => !isWebPlatform(p)); + const preferredPlatform = matchingPlatforms.find(p => p === userPreferredPlatform); + return { + proposedPlatform: preferredPlatform || nativePlatform || webPlatform, + nativePlatform, webPlatform + }; +} export function createClients() { return [ diff --git a/src/utils/ViewModel.js b/src/utils/ViewModel.js index b9afbc1c..274dd6b1 100644 --- a/src/utils/ViewModel.js +++ b/src/utils/ViewModel.js @@ -63,6 +63,7 @@ export class ViewModel extends EventEmitter { get request() { return this._options.request; } get origin() { return this._options.origin; } get openLink() { return this._options.openLink; } + get setTimeout() { return this._options.setTimeout; } get platforms() { return this._options.platforms; } get preferences() { return this._options.preferences; } @@ -71,6 +72,7 @@ export class ViewModel extends EventEmitter { request: this.request, origin: this.origin, openLink: this.openLink, + setTimeout: this.setTimeout, platforms: this.platforms, preferences: this.preferences, }, options);