diff --git a/css/main.css b/css/main.css index 03aa99a..5c2196a 100644 --- a/css/main.css +++ b/css/main.css @@ -122,4 +122,34 @@ textarea { a { color: var(--link); +} + +.ClientListView ul { + list-style: none; + padding: 0; +} + +.ClientView .header { + display: flex; +} + +.ClientView .description { + flex: 1; +} + +.ClientView h3 { + margin-top: 0; +} + +.ClientView .icon { + border-radius: 8px; + background-repeat: no-repeat; + background-size: cover; + width: 60px; + height: 60px; +} + + +.ClientView .icon.element-io { + background-image: url('../images/client-icons/element.svg'); } \ No newline at end of file diff --git a/images/client-icons/element.svg b/images/client-icons/element.svg new file mode 100644 index 0000000..95ecfa1 --- /dev/null +++ b/images/client-icons/element.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/images/matrix-logo.svg b/images/matrix-logo.svg new file mode 100644 index 0000000..9f2c322 --- /dev/null +++ b/images/matrix-logo.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/index.html b/index.html index d9e009c..c7b6418 100644 --- a/index.html +++ b/index.html @@ -1,8 +1,9 @@ - + matrix.to - you're invited to chat on matrix + diff --git a/src/RootViewModel.js b/src/RootViewModel.js index 099b4e2..9c1a43a 100644 --- a/src/RootViewModel.js +++ b/src/RootViewModel.js @@ -17,6 +17,7 @@ limitations under the License. import {Link} from "./Link.js"; import {ViewModel} from "./utils/ViewModel.js"; import {PreviewViewModel} from "./preview/PreviewViewModel.js"; +import {Element} from "./client/clients/Element.js"; export class RootViewModel extends ViewModel { constructor(options) { @@ -28,9 +29,13 @@ export class RootViewModel extends ViewModel { _updateChildVMs(oldLink) { if (this.link) { if (!oldLink || !oldLink.equals(this.link)) { + const element = new Element(); this.previewViewModel = new PreviewViewModel(this.childOptions({ link: this.link, - consentedServers: this.link.servers + consentedServers: this.link.servers, + // preferredClient: element, + // preferredPlatform: this.platforms[0], + clients: [element] })); this.previewViewModel.load(); } diff --git a/src/client/ClientListView.js b/src/client/ClientListView.js new file mode 100644 index 0000000..ef3ae16 --- /dev/null +++ b/src/client/ClientListView.js @@ -0,0 +1,28 @@ +/* +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 {TemplateView} from "../utils/TemplateView.js"; +import {ClientView} from "./ClientView.js"; + +export class ClientListView extends TemplateView { + render(t, vm) { + const clients = vm.clients.map(clientViewModel => t.view(new ClientView(clientViewModel))); + return t.div({className: "ClientListView"}, [ + t.h3("You need an app to continue"), + t.ul({className: "ClientListView"}, clients) + ]); + } +} diff --git a/src/client/ClientListViewModel.js b/src/client/ClientListViewModel.js new file mode 100644 index 0000000..c998075 --- /dev/null +++ b/src/client/ClientListViewModel.js @@ -0,0 +1,27 @@ +/* +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 {isWebPlatform, Platform} from "./Platform.js"; +import {ClientViewModel} from "./ClientViewModel.js"; +import {ViewModel} from "../utils/ViewModel.js"; + +export class ClientListViewModel extends ViewModel { + constructor(options) { + super(options); + const {clients, link} = options; + this.clients = clients.map(client => new ClientViewModel(this.childOptions({client, link}))); + } +} diff --git a/src/client/ClientView.js b/src/client/ClientView.js new file mode 100644 index 0000000..d5c789e --- /dev/null +++ b/src/client/ClientView.js @@ -0,0 +1,34 @@ +/* +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 {TemplateView} from "../utils/TemplateView.js"; + +export class ClientView extends TemplateView { + render(t, vm) { + return t.li({className: "ClientView"}, [ + t.div({className: "header"}, [ + t.div({className: "description"}, [ + t.h3(vm.name), + t.p(vm.description), + ]), + t.div({className: `icon ${vm.clientId}`}) + ]), + t.div({className: "actions"}, vm.actions.map(a => { + return t.a({href: a.url, className: a.kind, rel: "noopener noreferrer", onClick: () => a.activated()}, a.label); + })) + ]); + } +} \ No newline at end of file diff --git a/src/client/ClientViewModel.js b/src/client/ClientViewModel.js new file mode 100644 index 0000000..1ad7c23 --- /dev/null +++ b/src/client/ClientViewModel.js @@ -0,0 +1,66 @@ +/* +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 {isWebPlatform, Platform} from "./Platform.js"; +import {ViewModel} from "../utils/ViewModel.js"; + +export class ClientViewModel extends ViewModel { + constructor(options) { + super(options); + const {client, link} = options; + this._client = client; + const supportedPlatforms = client.platforms; + const matchingPlatforms = this.platforms.filter(p => { + return supportedPlatforms.includes(p); + }); + const nativePlatform = matchingPlatforms.find(p => !isWebPlatform(p)); + const webPlatform = this.platforms.find(p => isWebPlatform(p)); + this.actions = this._createActions(client, link, nativePlatform, webPlatform); + this.name = this._client.getName(nativePlatform || webPlatform); + } + + _createActions(client, link, nativePlatform, webPlatform) { + let actions = []; + if (nativePlatform) { + const nativeActions = client.getInstallLinks(nativePlatform).map(installLink => { + return { + label: installLink.description, + url: installLink.createInstallURL(link), + kind: installLink.channelId, + activated() {}, + }; + }); + actions.push(...nativeActions); + } + if (webPlatform) { + actions.push({ + label: `Or open in ${client.getName(webPlatform)}`, + url: client.getDeepLink(webPlatform, link), + kind: "open-in-web", + activated() {}, + }); + } + return actions; + } + + get description() { + return this._client.description; + } + + get clientId() { + return this._client.id; + } +} \ No newline at end of file diff --git a/src/client/Platform.js b/src/client/Platform.js new file mode 100644 index 0000000..a5b3c15 --- /dev/null +++ b/src/client/Platform.js @@ -0,0 +1,36 @@ +/* +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 {createEnum} from "../utils/enum.js"; + +export const Platform = createEnum( + "DesktopWeb", + "MobileWeb", + "Android", + "iOS", + "Windows", + "macOS", + "Linux" +); + +export function guessApplicablePlatforms(userAgent) { + // use https://github.com/faisalman/ua-parser-js to guess, and pass as RootVM options + return [Platform.DesktopWeb, Platform.Linux]; +} + +export function isWebPlatform(p) { + return p === Platform.DesktopWeb || p === Platform.MobileWeb; +} \ No newline at end of file diff --git a/src/client/clients/Element.js b/src/client/clients/Element.js new file mode 100644 index 0000000..13f5db5 --- /dev/null +++ b/src/client/clients/Element.js @@ -0,0 +1,81 @@ +/* +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 {Maturity, Platform, LinkKind, + FDroidLink, AppleStoreLink, PlayStoreLink, WebsiteLink} from "../types.js"; + +/** + * Information on how to deep link to a given matrix client. + */ +export class Element { + /* should only contain alphanumerical and -_, no dots (needs to be usable as css class) */ + get id() { return "element-io"; } + + get platforms() { + return [ + Platform.Android, Platform.iOS, + Platform.Windows, Platform.macOS, Platform.Linux, + Platform.DesktopWeb, Platform.MobileWeb + ]; + } + + get description() { return 'Fully-featured Matrix client'; } + + getMaturity(platform) { return Maturity.Stable; } + + getLinkSupport(platform, link) { return true; } + + getDeepLink(platform, link) { + let fragmentPath; + switch (link.kind) { + case LinkKind.User: + fragmentPath = `user/${link.identifier}`; + break; + case LinkKind.Room: + fragmentPath = `room/${link.identifier}`; + break; + case LinkKind.Group: + fragmentPath = `group/${link.identifier}`; + break; + case LinkKind.Event: + fragmentPath = `room/${link.identifier}/${link.eventId}`; + break; + } + if (platform === Platform.DesktopWeb || platform === Platform.MobileWeb || platform === Platform.iOS) { + return `https://app.element.io/#/${fragmentPath}`; + } else { + return `element://${fragmentPath}`; + } + } + + getLinkInstructions(platform, link) {} + + getName(platform) { + if (platform === Platform.DesktopWeb || platform === Platform.MobileWeb) { + return "Element Web"; + } else { + return "Element"; + } + } + + getInstallLinks(platform) { + switch (platform) { + case Platform.iOS: return [new AppleStoreLink('vector', 'id1083446067')]; + case Platform.Android: return [new PlayStoreLink('im.vector.app'), new FDroidLink('im.vector.app')]; + default: return [new WebsiteLink("https://element.io/get-started")]; + } + } +} \ No newline at end of file diff --git a/src/client/types.js b/src/client/types.js new file mode 100644 index 0000000..b6fda03 --- /dev/null +++ b/src/client/types.js @@ -0,0 +1,94 @@ +/* +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 {createEnum} from "../utils/enum.js"; +export const Maturity = createEnum("Alpha", "Beta", "Stable"); +export {LinkKind} from "../Link.js"; +export {Platform} from "./Platform.js"; + +export class AppleStoreLink { + constructor(org, appId) { + this._org = org; + this._appId = appId; + } + + createInstallURL(link) { + return `https://apps.apple.com/app/${encodeURIComponent(this._org)}/${encodeURIComponent(this._appId)}`; + } + + get channelId() { + return "apple-app-store"; + } + + get description() { + return "Download on the App Store"; + } +} + +export class PlayStoreLink { + constructor(appId) { + this._appId = appId; + } + + createInstallURL(link) { + return `https://play.google.com/store/apps/details?id=${encodeURIComponent(this._appId)}&referrer=${encodeURIComponent(link.identifier)}`; + } + + get channelId() { + return "play-store"; + } + + get description() { + return "Get it on Google Play"; + } +} + +export class FDroidLink { + constructor(appId) { + this._appId = appId; + } + + createInstallURL(link) { + return `https://f-droid.org/packages/${encodeURIComponent(this._appId)}`; + } + + get channelId() { + return "fdroid"; + } + + get description() { + return "Get it on F-Droid"; + } +} + + +export class WebsiteLink { + constructor(url) { + this._url = url; + } + + createInstallURL(link) { + return this._url; + } + + get channelId() { + return "website"; + } + + get description() { + return `Download from ${new URL(this._url).hostname}`; + } +} \ No newline at end of file diff --git a/src/main.js b/src/main.js index 2ce7f76..de41b5d 100644 --- a/src/main.js +++ b/src/main.js @@ -1,9 +1,14 @@ import {xhrRequest} from "./utils/xhr.js"; import {RootViewModel} from "./RootViewModel.js"; import {RootView} from "./RootView.js"; +import {guessApplicablePlatforms} from "./client/Platform.js"; export async function main(container) { - const vm = new RootViewModel({request: xhrRequest}); + const vm = new RootViewModel({ + request: xhrRequest, + openLink: url => location.href = url, + platforms: guessApplicablePlatforms(navigator.userAgent), + }); vm.updateHash(location.hash); window.__rootvm = vm; const view = new RootView(vm); diff --git a/src/preview/PreviewView.js b/src/preview/PreviewView.js index 3dc614b..f4c217b 100644 --- a/src/preview/PreviewView.js +++ b/src/preview/PreviewView.js @@ -15,18 +15,23 @@ limitations under the License. */ import {TemplateView} from "../utils/TemplateView.js"; +import {ClientListView} from "../client/ClientListView.js"; export class PreviewView extends TemplateView { render(t, vm) { return t.div({className: "PreviewView card"}, [ t.h2({className: {hidden: vm => !vm.loading}}, "Loading preview…"), - t.div({className: {preview: true, hidden: vm => vm.loading}}, [ - t.p(t.img({className: "avatar", src: vm => vm.avatarUrl})), - t.div({className: "profileInfo"}, [ - t.h2(vm => vm.name), - t.p(vm => vm.identifier), - t.p(["Preview from ", vm => vm.previewDomain]), - ]) + t.div({className: {hidden: vm => vm.loading}}, [ + t.div({className: "preview"}, [ + t.p(t.img({className: "avatar", src: vm => vm.avatarUrl})), + t.div({className: "profileInfo"}, [ + t.h2(vm => vm.name), + t.p(vm => vm.identifier), + t.p(["Preview from ", vm => vm.previewDomain]), + ]), + ]), + t.p({hidden: vm => !!vm.clientsViewModel}, t.button({onClick: () => vm.accept()}, vm => vm.acceptLabel)), + t.mapView(vm => vm.clientsViewModel, vm => vm ? new ClientListView(vm) : null) ]) ]); } diff --git a/src/preview/PreviewViewModel.js b/src/preview/PreviewViewModel.js index 5911c02..e232ca4 100644 --- a/src/preview/PreviewViewModel.js +++ b/src/preview/PreviewViewModel.js @@ -17,17 +17,28 @@ limitations under the License. import {LinkKind} from "../Link.js"; import {ViewModel} from "../utils/ViewModel.js"; import {resolveServer} from "./HomeServer.js"; +import {ClientListViewModel} from "../client/ClientListViewModel.js"; export class PreviewViewModel extends ViewModel { constructor(options) { super(options); - const {link, consentedServers} = options; + const { + link, consentedServers, + preferredClient, preferredPlatform, clients + } = options; this._link = link; this._consentedServers = consentedServers; + this._preferredClient = preferredClient; + // used to differentiate web from native if a client supports both + this._preferredPlatform = preferredPlatform; + this._clients = clients; + this.loading = false; this.name = null; this.avatarUrl = null; this.previewDomain = null; + this.clientsViewModel = null; + this.acceptInstructions = null; } async load() { @@ -62,4 +73,28 @@ export class PreviewViewModel extends ViewModel { get identifier() { return this._link.identifier; } + + get acceptLabel() { + if (this._preferredClient) { + return `Open in ${this._preferredClient.getName(this._preferredPlatform)}`; + } else { + return "Choose app"; + } + } + + accept() { + if (this._preferredClient) { + if (this._preferredClient.getLinkSupport(this._preferredPlatform, this._link)) { + const deepLink = this._preferredClient.getDeepLink(this._preferredPlatform, this._link); + this.openLink(deepLink); + // show "looks like you don't have the native app installed" + } else { + this.acceptInstructions = this._preferredClient.getLinkInstructions(this._preferredPlatform, this._link); + } + } else { + this.clientsViewModel = new ClientListViewModel(this.childOptions({clients: this._clients, link: this._link})); + // show client list + } + this.emitChange(); + } } \ No newline at end of file diff --git a/src/utils/ViewModel.js b/src/utils/ViewModel.js index 4cc14e1..3cc34f2 100644 --- a/src/utils/ViewModel.js +++ b/src/utils/ViewModel.js @@ -60,11 +60,15 @@ export class ViewModel extends EventEmitter { this.emit("change"); } - get request() { - return this._options.request; - } + get request() { return this._options.request; } + get openLink() { return this._options.openLink; } + get platforms() { return this._options.platforms; } childOptions(options = {}) { - return Object.assign({request: this.request}, options); + return Object.assign({ + request: this.request, + openLink: this.openLink, + platforms: this.platforms, + }, options); } } \ No newline at end of file