Merge pull request #219 from matrix-org/t3chguy/msc3266

This commit is contained in:
Michael Telatynski 2021-08-13 17:00:19 +01:00 committed by GitHub
commit c25a9dae4d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 828 additions and 798 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
node_modules node_modules
build build
*.tar.gz *.tar.gz
/.idea

View File

@ -21,31 +21,31 @@ limitations under the License.
@import url('open.css'); @import url('open.css');
:root { :root {
--app-background: #f4f4f4; --app-background: #f4f4f4;
--background: #ffffff; --background: #ffffff;
--foreground: #000000; --foreground: #000000;
--font: #333333; --font: #333333;
--grey: #666666; --grey: #666666;
--accent: #0098d4; --accent: #0098d4;
--error: #d6001c; --error: #d6001c;
--link: #0098d4; --link: #0098d4;
--borders: #f4f4f4; --borders: #f4f4f4;
--lightgrey: #E6E6E6; --lightgrey: #E6E6E6;
--spinner-stroke-size: 2px; --spinner-stroke-size: 2px;
} }
html { html {
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
body { body {
background-color: var(--app-background); background-color: var(--app-background);
background-image: url('../images/background.svg'); background-image: url('../images/background.svg');
background-attachment: fixed; background-attachment: fixed;
background-repeat: no-repeat; background-repeat: no-repeat;
background-size: auto; background-size: auto;
background-position: center -50px; background-position: center -50px;
height: 100%; height: 100%;
width: 100%; width: 100%;
font-size: 14px; font-size: 14px;
@ -89,12 +89,12 @@ input[type="checkbox"], input[type="radio"] {
.RootView { .RootView {
margin: 0 auto; margin: 0 auto;
max-width: 480px; max-width: 480px;
width: 100%; width: 100%;
} }
.card { .card {
background-color: var(--background); background-color: var(--background);
border-radius: 16px; border-radius: 16px;
box-shadow: 0px 18px 24px rgba(0, 0, 0, 0.06); box-shadow: 0px 18px 24px rgba(0, 0, 0, 0.06);
} }
@ -104,20 +104,20 @@ input[type="checkbox"], input[type="radio"] {
} }
.hidden { .hidden {
display: none !important; display: none !important;
} }
@media screen and (max-width: 480px) { @media screen and (max-width: 480px) {
body { body {
background-image: none; background-image: none;
background-color: var(--background); background-color: var(--background);
padding: 0; padding: 0;
} }
.card { .card {
border-radius: unset; border-radius: unset;
box-shadow: unset; box-shadow: unset;
} }
} }
@ -141,7 +141,7 @@ input[type="checkbox"], input[type="radio"] {
} }
a, button.text { a, button.text {
color: var(--link); color: var(--link);
} }
button.text { button.text {

View File

@ -22,6 +22,10 @@
height: 64px; height: 64px;
} }
.PreviewView .mxSpace .avatar {
border-radius: 12px;
}
.PreviewView .defaultAvatar { .PreviewView .defaultAvatar {
width: 64px; width: 64px;
height: 64px; height: 64px;

View File

@ -1,43 +1,43 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 22.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> <!-- Generator: Adobe Illustrator 22.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" <svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 181.4 181.9" style="enable-background:new 0 0 181.4 181.9;" xml:space="preserve"> viewBox="0 0 181.4 181.9" style="enable-background:new 0 0 181.4 181.9;" xml:space="preserve">
<style type="text/css"> <style type="text/css">
.st0{fill:url(#SVGID_1_);} .st0{fill:url(#SVGID_1_);}
.st1{fill:#F094BE;} .st1{fill:#F094BE;}
.st2{fill:#4D3F92;} .st2{fill:#4D3F92;}
.st3{fill:#FFFFFF;} .st3{fill:#FFFFFF;}
</style> </style>
<g id="Capa_1"> <g id="Capa_1">
<rect x="0" y="0" style="color:#FFFFFF" width="181.4" height="181.9" class="st3"/> <rect x="0" y="0" style="color:#FFFFFF" width="181.4" height="181.9" class="st3"/>
</g> </g>
<g id="Capa_2"> <g id="Capa_2">
<g> <g>
<path class="st2" d="M151.6,95.1c1.5-0.3,2.8-1,3.8-2c4-5.3,0.8-11.8-4.5-12.6c-0.8,0-1.5-0.8-1.5-1.5c0-0.3,0-0.5,0-0.5 <path class="st2" d="M151.6,95.1c1.5-0.3,2.8-1,3.8-2c4-5.3,0.8-11.8-4.5-12.6c-0.8,0-1.5-0.8-1.5-1.5c0-0.3,0-0.5,0-0.5
c0.8-0.8,1.5-1.8,2.5-3.3c8.1-10.8,11.8-50.6,3.8-53.7c-9.8-3.3-29.7,6.3-38.3,17.4c-0.5-0.3-1-1-1-1.8c0.3-3-1.3-5.5-3.5-6.8 c0.8-0.8,1.5-1.8,2.5-3.3c8.1-10.8,11.8-50.6,3.8-53.7c-9.8-3.3-29.7,6.3-38.3,17.4c-0.5-0.3-1-1-1-1.8c0.3-3-1.3-5.5-3.5-6.8
c-4.5-2.3-8.8,0-10.6,3.3c-0.5,0.8-1.3,1.3-2,1c-0.8,0-1.5-0.8-1.5-1.5c-0.5-2.5-2-4.5-4.3-5.5c-4.8-2-9.8,0.8-10.6,5.3 c-4.5-2.3-8.8,0-10.6,3.3c-0.5,0.8-1.3,1.3-2,1c-0.8,0-1.5-0.8-1.5-1.5c-0.5-2.5-2-4.5-4.3-5.5c-4.8-2-9.8,0.8-10.6,5.3
c-0.3,0.8-0.8,1.5-1.5,1.5c-0.8,0.3-1.5-0.3-2-1c-1.5-2.3-4-3.8-6.5-3.8c-4,0-7.6,3.3-7.8,7.3v0.3v0.3c0,0.8-0.5,1.5-1,1.8h-0.3 c-0.3,0.8-0.8,1.5-1.5,1.5c-0.8,0.3-1.5-0.3-2-1c-1.5-2.3-4-3.8-6.5-3.8c-4,0-7.6,3.3-7.8,7.3v0.3v0.3c0,0.8-0.5,1.5-1,1.8h-0.3
c-8.3-10.8-28.5-20.7-38.5-17.4c-8.1,2.8-4.3,42.6,4,53.4c1.5,2,2.8,3.5,3.8,4.5c-0.3,0.8-1,1.5-1.8,1.5c-1.3,0-2.5,0.5-3.5,1.3 c-8.3-10.8-28.5-20.7-38.5-17.4c-8.1,2.8-4.3,42.6,4,53.4c1.5,2,2.8,3.5,3.8,4.5c-0.3,0.8-1,1.5-1.8,1.5c-1.3,0-2.5,0.5-3.5,1.3
c-5.3,5-2.3,12.1,3,13.4c0.8,0.3,1.5,1,1.5,1.8c0,0.8-0.5,1.8-1.3,2c-1,0.5-2,1-2.8,2c-4,5.8,0,12.3,5.5,12.3 c-5.3,5-2.3,12.1,3,13.4c0.8,0.3,1.5,1,1.5,1.8c0,0.8-0.5,1.8-1.3,2c-1,0.5-2,1-2.8,2c-4,5.8,0,12.3,5.5,12.3
c0.8,0,1.5,0.5,1.8,1.3c0.3,0.8,0.3,1.5-0.5,2c-1.5,1.5-2.3,3.5-2,5.5c0.3,2.8,2,5.3,4.8,6.5c1.5,0.8,3,0.8,4.5,0.5 c0.8,0,1.5,0.5,1.8,1.3c0.3,0.8,0.3,1.5-0.5,2c-1.5,1.5-2.3,3.5-2,5.5c0.3,2.8,2,5.3,4.8,6.5c1.5,0.8,3,0.8,4.5,0.5
c0.8-0.3,1.5,0,2,0.8c0.5,0.5,0.5,1.5,0.3,2c-0.8,1.5-1,3.3-0.5,5c0.8,2.8,2.8,4.8,5.5,5.5c2.5,0.5,4.3-0.3,5.5-0.8 c0.8-0.3,1.5,0,2,0.8c0.5,0.5,0.5,1.5,0.3,2c-0.8,1.5-1,3.3-0.5,5c0.8,2.8,2.8,4.8,5.5,5.5c2.5,0.5,4.3-0.3,5.5-0.8
c0.5-0.3-3.3,9.1-6,15.4c-0.8,2,1.3,4.3,3.5,3.3c8.3-3.8,22.2-10.3,22.2-9.8c0.5,5.3,6.5,9.1,12.3,5.3c1.3-0.8,2-2.3,2.3-3.5 c0.5-0.3-3.3,9.1-6,15.4c-0.8,2,1.3,4.3,3.5,3.3c8.3-3.8,22.2-10.3,22.2-9.8c0.5,5.3,6.5,9.1,12.3,5.3c1.3-0.8,2-2.3,2.3-3.5
c0.3-0.8,1-1.5,2-1.5c1,0,1.8,0.5,2,1.5c0.3,1.3,0.8,2.3,1.8,3c5.8,4.5,12.3,0.8,12.8-4.8c0-0.8,0.5-1.5,1.3-1.8 c0.3-0.8,1-1.5,2-1.5c1,0,1.8,0.5,2,1.5c0.3,1.3,0.8,2.3,1.8,3c5.8,4.5,12.3,0.8,12.8-4.8c0-0.8,0.5-1.5,1.3-1.8
c0.8-0.3,1.5,0,2,0.5c1.5,1.5,3.3,2.5,5.3,2.5l0,0c2.5,0,5-1.3,6.5-3.8c1-1.5,1.3-3,1-5c0-0.8,0.3-1.5,0.8-2c0.5-0.5,1.5-0.5,2,0 c0.8-0.3,1.5,0,2,0.5c1.5,1.5,3.3,2.5,5.3,2.5l0,0c2.5,0,5-1.3,6.5-3.8c1-1.5,1.3-3,1-5c0-0.8,0.3-1.5,0.8-2c0.5-0.5,1.5-0.5,2,0
c1.5,0.8,3.3,1.3,5,0.8c2.8-0.5,5-2.8,5.8-5.3c0.5-1.8,0.3-3.5-0.5-5.3c-0.3-0.8-0.3-1.5,0.3-2s1.3-0.8,2-0.8 c1.5,0.8,3.3,1.3,5,0.8c2.8-0.5,5-2.8,5.8-5.3c0.5-1.8,0.3-3.5-0.5-5.3c-0.3-0.8-0.3-1.5,0.3-2s1.3-0.8,2-0.8
c1.8,0.3,3.3,0.3,4.8-0.5c2.3-1,3.8-3,4.3-5.5c0.5-2.5-0.3-4.8-2-6.5c-0.5-0.5-0.8-1.3-0.5-2s1-1.3,1.8-1.3c1.8,0,3.8-0.5,5-2 c1.8,0.3,3.3,0.3,4.8-0.5c2.3-1,3.8-3,4.3-5.5c0.5-2.5-0.3-4.8-2-6.5c-0.5-0.5-0.8-1.3-0.5-2s1-1.3,1.8-1.3c1.8,0,3.8-0.5,5-2
c4.3-4.5,2.3-10.6-2.5-12.6c-0.8-0.3-1.3-1-1.3-2C150.1,95.8,150.8,95.1,151.6,95.1z"/> c4.3-4.5,2.3-10.6-2.5-12.6c-0.8-0.3-1.3-1-1.3-2C150.1,95.8,150.8,95.1,151.6,95.1z"/>
<path class="st3" d="M131.4,42.2c0.5,1.5,0.5,3,0,4.5c-0.3,0.8,0,1.5,0.5,2s1.3,0.8,2,0.5c1-0.5,2-0.5,3-0.5c2.3,0,4.3,1,5.8,3 <path class="st3" d="M131.4,42.2c0.5,1.5,0.5,3,0,4.5c-0.3,0.8,0,1.5,0.5,2s1.3,0.8,2,0.5c1-0.5,2-0.5,3-0.5c2.3,0,4.3,1,5.8,3
c1,1.3,1.8,3,1.5,4.8c0,1.5-0.5,2.8-1.3,4c-0.5,0.5-0.5,1.5,0,2c0.3,0.3,0.5,0.8,1,0.8c1-0.3,2-1,2.8-2c4.5-6.3,5.3-26.2,0.8-27.7 c1,1.3,1.8,3,1.5,4.8c0,1.5-0.5,2.8-1.3,4c-0.5,0.5-0.5,1.5,0,2c0.3,0.3,0.5,0.8,1,0.8c1-0.3,2-1,2.8-2c4.5-6.3,5.3-26.2,0.8-27.7
c-4.5-1.5-12.3,1.5-17.9,6C130.7,40.1,131.2,40.9,131.4,42.2z"/> c-4.5-1.5-12.3,1.5-17.9,6C130.7,40.1,131.2,40.9,131.4,42.2z"/>
<path class="st3" d="M39,63.6c0.3-0.3,0.5-0.5,0.8-0.8c0.5-0.8,0.3-1.5,0-2C38.5,59,38.2,57,38.5,55c0.5-2.8,2.8-5,5.5-5.8 <path class="st3" d="M39,63.6c0.3-0.3,0.5-0.5,0.8-0.8c0.5-0.8,0.3-1.5,0-2C38.5,59,38.2,57,38.5,55c0.5-2.8,2.8-5,5.5-5.8
c1.5-0.5,3-0.3,4.5,0.3c0.8,0.3,1.5,0,2-0.5c0.5-0.5,0.8-1.3,0.5-2c-0.5-1.5-0.5-3,0-4.5c0.3-1,0.8-2,1.5-2.8 c1.5-0.5,3-0.3,4.5,0.3c0.8,0.3,1.5,0,2-0.5c0.5-0.5,0.8-1.3,0.5-2c-0.5-1.5-0.5-3,0-4.5c0.3-1,0.8-2,1.5-2.8
c-5.5-4.5-13.9-7.8-18.4-6.3S30.4,54.8,35,61.1C36,62.6,37.2,63.3,39,63.6z"/> c-5.5-4.5-13.9-7.8-18.4-6.3S30.4,54.8,35,61.1C36,62.6,37.2,63.3,39,63.6z"/>
<g> <g>
<circle class="st3" cx="60.9" cy="94.6" r="9.3"/> <circle class="st3" cx="60.9" cy="94.6" r="9.3"/>
<path class="st3" d="M100.7,94.6c0,5.3-4.3,9.3-9.3,9.3c-5.3,0-9.3-4.3-9.3-9.3S100.7,89.3,100.7,94.6z"/> <path class="st3" d="M100.7,94.6c0,5.3-4.3,9.3-9.3,9.3c-5.3,0-9.3-4.3-9.3-9.3S100.7,89.3,100.7,94.6z"/>
<circle class="st3" cx="121.6" cy="94.6" r="9.3"/> <circle class="st3" cx="121.6" cy="94.6" r="9.3"/>
</g> </g>
</g> </g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@ -1,17 +1,17 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<title>You're invited to talk on Matrix</title> <title>You're invited to talk on Matrix</title>
<meta name="description" content="You're invited to talk on Matrix"> <meta name="description" content="You're invited to talk on Matrix">
<meta name="viewport" content="width=device-width, user-scalable=no"> <meta name="viewport" content="width=device-width, user-scalable=no">
<link rel="stylesheet" type="text/css" href="css/main.css"> <link rel="stylesheet" type="text/css" href="css/main.css">
</head> </head>
<body> <body>
<script id="main" type="module"> <script id="main" type="module">
import {main} from "./src/main.js"; import {main} from "./src/main.js";
main(document.body); main(document.body);
</script> </script>
<noscript> <noscript>
<h1>Please enable javascript</h1> <h1>Please enable javascript</h1>
<p>Matrix.to is a preview service from chat rooms, people and communities on <a href="https://matrix.org">Matrix</a>.</p> <p>Matrix.to is a preview service from chat rooms, people and communities on <a href="https://matrix.org">Matrix</a>.</p>

View File

@ -23,25 +23,26 @@ const projectDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".
// Serve up parent directory with cache disabled // Serve up parent directory with cache disabled
const serve = serveStatic( const serve = serveStatic(
projectDir, projectDir,
{ {
etag: false, etag: false,
setHeaders: res => { setHeaders: res => {
res.setHeader("Pragma", "no-cache"); res.setHeader("Pragma", "no-cache");
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
res.setHeader("Expires", "Wed, 21 Oct 2015 07:28:00 GMT"); res.setHeader("Expires", "Wed, 21 Oct 2015 07:28:00 GMT");
// same CSP as matrix.to server is using, so local testing happens under similar environment // same CSP as matrix.to server is using, so local testing happens under similar environment
res.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src * data:; connect-src *; font-src 'self'; manifest-src 'self'; form-action 'self'; navigate-to *;"); res.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src * data:; connect-src *; font-src 'self'; manifest-src 'self'; form-action 'self'; navigate-to *;");
}, },
index: ['index.html', 'index.htm'] index: ['index.html', 'index.htm']
} }
); );
// Create server // Create server
const server = http.createServer(function onRequest (req, res) { const server = http.createServer(function onRequest (req, res) {
console.log(req.method, req.url); console.log(req.method, req.url);
serve(req, res, finalhandler(req, res)) serve(req, res, finalhandler(req, res))
}); });
// Listen // Listen
server.listen(5000); server.listen(5000);
console.log("Listening on port 5000");

View File

@ -24,20 +24,20 @@ const EVENTID_PATTERN = /^$([^:]+):(.+)$/;
const GROUPID_PATTERN = /^\+([^:]+):(.+)$/; const GROUPID_PATTERN = /^\+([^:]+):(.+)$/;
export const IdentifierKind = createEnum( export const IdentifierKind = createEnum(
"RoomId", "RoomId",
"RoomAlias", "RoomAlias",
"UserId", "UserId",
"GroupId", "GroupId",
); );
function asPrefix(identifierKind) { function asPrefix(identifierKind) {
switch (identifierKind) { switch (identifierKind) {
case IdentifierKind.RoomId: return "!"; case IdentifierKind.RoomId: return "!";
case IdentifierKind.RoomAlias: return "#"; case IdentifierKind.RoomAlias: return "#";
case IdentifierKind.GroupId: return "+"; case IdentifierKind.GroupId: return "+";
case IdentifierKind.UserId: return "@"; case IdentifierKind.UserId: return "@";
default: throw new Error("invalid id kind " + identifierKind); default: throw new Error("invalid id kind " + identifierKind);
} }
} }
function getWebInstanceMap(queryParams) { function getWebInstanceMap(queryParams) {
@ -56,19 +56,19 @@ function getWebInstanceMap(queryParams) {
} }
export function getLabelForLinkKind(kind) { export function getLabelForLinkKind(kind) {
switch (kind) { switch (kind) {
case LinkKind.User: return "Start chat"; case LinkKind.User: return "Start chat";
case LinkKind.Room: return "View room"; case LinkKind.Room: return "View room";
case LinkKind.Group: return "View community"; case LinkKind.Group: return "View community";
case LinkKind.Event: return "View message"; case LinkKind.Event: return "View message";
} }
} }
export const LinkKind = createEnum( export const LinkKind = createEnum(
"Room", "Room",
"User", "User",
"Group", "Group",
"Event" "Event"
) )
export class Link { export class Link {
@ -81,106 +81,106 @@ export class Link {
); );
} }
static parse(fragment) { static parse(fragment) {
if (!fragment) { if (!fragment) {
return null; return null;
} }
let [linkStr, queryParamsStr] = fragment.split("?"); let [linkStr, queryParamsStr] = fragment.split("?");
let viaServers = []; let viaServers = [];
let clientId = null; let clientId = null;
let webInstances = {}; let webInstances = {};
if (queryParamsStr) { if (queryParamsStr) {
const queryParams = queryParamsStr.split("&").map(pair => { const queryParams = queryParamsStr.split("&").map(pair => {
const [key, value] = pair.split("="); const [key, value] = pair.split("=");
return [decodeURIComponent(key), decodeURIComponent(value)]; return [decodeURIComponent(key), decodeURIComponent(value)];
}); });
viaServers = queryParams viaServers = queryParams
.filter(([key, value]) => key === "via") .filter(([key, value]) => key === "via")
.map(([,value]) => value); .map(([,value]) => value);
const clientParam = queryParams.find(([key]) => key === "client"); const clientParam = queryParams.find(([key]) => key === "client");
if (clientParam) { if (clientParam) {
clientId = clientParam[1]; clientId = clientParam[1];
} }
webInstances = getWebInstanceMap(queryParams); webInstances = getWebInstanceMap(queryParams);
} }
if (linkStr.startsWith("#/")) { if (linkStr.startsWith("#/")) {
linkStr = linkStr.substr(2); linkStr = linkStr.substr(2);
} }
const [identifier, eventId] = linkStr.split("/"); const [identifier, eventId] = linkStr.split("/");
let matches; let matches;
matches = USERID_PATTERN.exec(identifier); matches = USERID_PATTERN.exec(identifier);
if (matches) { if (matches) {
const server = matches[2]; const server = matches[2];
const localPart = matches[1]; const localPart = matches[1];
return new Link(clientId, viaServers, IdentifierKind.UserId, localPart, server, webInstances); return new Link(clientId, viaServers, IdentifierKind.UserId, localPart, server, webInstances);
} }
matches = ROOMALIAS_PATTERN.exec(identifier); matches = ROOMALIAS_PATTERN.exec(identifier);
if (matches) { if (matches) {
const server = matches[2]; const server = matches[2];
const localPart = matches[1]; const localPart = matches[1];
return new Link(clientId, viaServers, IdentifierKind.RoomAlias, localPart, server, webInstances, eventId); return new Link(clientId, viaServers, IdentifierKind.RoomAlias, localPart, server, webInstances, eventId);
} }
matches = ROOMID_PATTERN.exec(identifier); matches = ROOMID_PATTERN.exec(identifier);
if (matches) { if (matches) {
const server = matches[2]; const server = matches[2];
const localPart = matches[1]; const localPart = matches[1];
return new Link(clientId, viaServers, IdentifierKind.RoomId, localPart, server, webInstances, eventId); return new Link(clientId, viaServers, IdentifierKind.RoomId, localPart, server, webInstances, eventId);
} }
matches = GROUPID_PATTERN.exec(identifier); matches = GROUPID_PATTERN.exec(identifier);
if (matches) { if (matches) {
const server = matches[2]; const server = matches[2];
const localPart = matches[1]; const localPart = matches[1];
return new Link(clientId, viaServers, IdentifierKind.GroupId, localPart, server, webInstances); return new Link(clientId, viaServers, IdentifierKind.GroupId, localPart, server, webInstances);
} }
return null; return null;
} }
constructor(clientId, viaServers, identifierKind, localPart, server, webInstances, eventId) { constructor(clientId, viaServers, identifierKind, localPart, server, webInstances, eventId) {
const servers = [server]; const servers = [server];
servers.push(...viaServers); servers.push(...viaServers);
this.webInstances = webInstances; this.webInstances = webInstances;
this.servers = orderedUnique(servers); this.servers = orderedUnique(servers);
this.identifierKind = identifierKind; this.identifierKind = identifierKind;
this.identifier = `${asPrefix(identifierKind)}${localPart}:${server}`; this.identifier = `${asPrefix(identifierKind)}${localPart}:${server}`;
this.eventId = eventId; this.eventId = eventId;
this.clientId = clientId; this.clientId = clientId;
} }
get kind() { get kind() {
if (this.eventId) { if (this.eventId) {
return LinkKind.Event; return LinkKind.Event;
} }
switch (this.identifierKind) { switch (this.identifierKind) {
case IdentifierKind.RoomId: case IdentifierKind.RoomId:
case IdentifierKind.RoomAlias: case IdentifierKind.RoomAlias:
return LinkKind.Room; return LinkKind.Room;
case IdentifierKind.UserId: case IdentifierKind.UserId:
return LinkKind.User; return LinkKind.User;
case IdentifierKind.GroupId: case IdentifierKind.GroupId:
return LinkKind.Group; return LinkKind.Group;
default: default:
return null; return null;
} }
} }
equals(link) { equals(link) {
return link && return link &&
link.identifier === this.identifier && link.identifier === this.identifier &&
this.servers.length === link.servers.length && this.servers.length === link.servers.length &&
this.servers.every((s, i) => link.servers[i] === s) && this.servers.every((s, i) => link.servers[i] === s) &&
Object.keys(this.webInstances).length === Object.keys(link.webInstances).length && Object.keys(this.webInstances).length === Object.keys(link.webInstances).length &&
Object.keys(this.webInstances).every(k => this.webInstances[k] === link.webInstances[k]); Object.keys(this.webInstances).every(k => this.webInstances[k] === link.webInstances[k]);
} }
toFragment() { toFragment() {
if (this.eventId) { if (this.eventId) {
return `/${this.identifier}/${this.eventId}`; return `/${this.identifier}/${this.eventId}`;
} else { } else {
return `/${this.identifier}`; return `/${this.identifier}`;
} }
} }
} }

View File

@ -17,17 +17,17 @@ limitations under the License.
import {createEnum} from "./utils/enum.js"; import {createEnum} from "./utils/enum.js";
export const Platform = createEnum( export const Platform = createEnum(
"DesktopWeb", "DesktopWeb",
"MobileWeb", "MobileWeb",
"Android", "Android",
"iOS", "iOS",
"Windows", "Windows",
"macOS", "macOS",
"Linux" "Linux"
); );
export function guessApplicablePlatforms(userAgent, platform) { export function guessApplicablePlatforms(userAgent, platform) {
// return [Platform.DesktopWeb, Platform.Linux]; // return [Platform.DesktopWeb, Platform.Linux];
let nativePlatform; let nativePlatform;
let webPlatform; let webPlatform;
if (/android/i.test(userAgent)) { if (/android/i.test(userAgent)) {
@ -55,10 +55,10 @@ export function guessApplicablePlatforms(userAgent, platform) {
} }
export function isWebPlatform(p) { export function isWebPlatform(p) {
return p === Platform.DesktopWeb || p === Platform.MobileWeb; return p === Platform.DesktopWeb || p === Platform.MobileWeb;
} }
export function isDesktopPlatform(p) { export function isDesktopPlatform(p) {
return p === Platform.Linux || p === Platform.Windows || p === Platform.macOS; return p === Platform.Linux || p === Platform.Windows || p === Platform.macOS;
} }

View File

@ -18,51 +18,51 @@ import {Platform} from "./Platform.js";
import {EventEmitter} from "./utils/ViewModel.js"; import {EventEmitter} from "./utils/ViewModel.js";
export class Preferences extends EventEmitter { export class Preferences extends EventEmitter {
constructor(localStorage) { constructor(localStorage) {
super(); super();
this._localStorage = localStorage; this._localStorage = localStorage;
this.clientId = null; this.clientId = null;
// used to differentiate web from native if a client supports both // used to differentiate web from native if a client supports both
this.platform = null; this.platform = null;
this.homeservers = null; this.homeservers = null;
const prefsStr = localStorage.getItem("preferred_client"); const prefsStr = localStorage.getItem("preferred_client");
if (prefsStr) { if (prefsStr) {
const {id, platform} = JSON.parse(prefsStr); const {id, platform} = JSON.parse(prefsStr);
this.clientId = id; this.clientId = id;
this.platform = Platform[platform]; this.platform = Platform[platform];
} }
const serversStr = localStorage.getItem("consented_servers"); const serversStr = localStorage.getItem("consented_servers");
if (serversStr) { if (serversStr) {
this.homeservers = JSON.parse(serversStr); this.homeservers = JSON.parse(serversStr);
} }
} }
setClient(id, platform) { setClient(id, platform) {
this.clientId = id; this.clientId = id;
platform = Platform[platform]; platform = Platform[platform];
this.platform = platform; this.platform = platform;
this._localStorage.setItem("preferred_client", JSON.stringify({id, platform})); this._localStorage.setItem("preferred_client", JSON.stringify({id, platform}));
this.emit("canClear") this.emit("canClear")
} }
setHomeservers(homeservers, persist) { setHomeservers(homeservers, persist) {
this.homeservers = homeservers; this.homeservers = homeservers;
if (persist) { if (persist) {
this._localStorage.setItem("consented_servers", JSON.stringify(homeservers)); this._localStorage.setItem("consented_servers", JSON.stringify(homeservers));
this.emit("canClear"); this.emit("canClear");
} }
} }
clear() { clear() {
this._localStorage.removeItem("preferred_client"); this._localStorage.removeItem("preferred_client");
this._localStorage.removeItem("consented_servers"); this._localStorage.removeItem("consented_servers");
this.clientId = null; this.clientId = null;
this.platform = null; this.platform = null;
this.homeservers = null; this.homeservers = null;
} }
get canClear() { get canClear() {
return !!this.clientId || !!this.platform || !!this.homeservers; return !!this.clientId || !!this.platform || !!this.homeservers;
} }
} }

View File

@ -20,25 +20,25 @@ import {CreateLinkView} from "./create/CreateLinkView.js";
import {LoadServerPolicyView} from "./policy/LoadServerPolicyView.js"; import {LoadServerPolicyView} from "./policy/LoadServerPolicyView.js";
export class RootView extends TemplateView { export class RootView extends TemplateView {
render(t, vm) { render(t, vm) {
return t.div({className: "RootView"}, [ return t.div({className: "RootView"}, [
t.mapView(vm => vm.openLinkViewModel, vm => vm ? new OpenLinkView(vm) : null), t.mapView(vm => vm.openLinkViewModel, vm => vm ? new OpenLinkView(vm) : null),
t.mapView(vm => vm.createLinkViewModel, vm => vm ? new CreateLinkView(vm) : null), t.mapView(vm => vm.createLinkViewModel, vm => vm ? new CreateLinkView(vm) : null),
t.mapView(vm => vm.loadServerPolicyViewModel, vm => vm ? new LoadServerPolicyView(vm) : null), t.mapView(vm => vm.loadServerPolicyViewModel, vm => vm ? new LoadServerPolicyView(vm) : null),
t.div({className: "footer"}, [ t.div({className: "footer"}, [
t.p(t.img({src: "images/matrix-logo.svg"})), t.p(t.img({src: "images/matrix-logo.svg"})),
t.p(["This invite uses ", externalLink(t, "https://matrix.org", "Matrix"), ", an open network for secure, decentralized communication."]), t.p(["This invite uses ", externalLink(t, "https://matrix.org", "Matrix"), ", an open network for secure, decentralized communication."]),
t.ul({className: "links"}, [ t.ul({className: "links"}, [
t.li(externalLink(t, "https://github.com/matrix-org/matrix.to", "GitHub project")), t.li(externalLink(t, "https://github.com/matrix-org/matrix.to", "GitHub project")),
t.li(externalLink(t, "https://github.com/matrix-org/matrix.to/tree/main/src/open/clients", "Add your app")), t.li(externalLink(t, "https://github.com/matrix-org/matrix.to/tree/main/src/open/clients", "Add your app")),
t.li({className: {hidden: vm => !vm.hasPreferences}}, t.li({className: {hidden: vm => !vm.hasPreferences}},
t.button({className: "text", onClick: () => vm.clearPreferences()}, "Clear preferences")), t.button({className: "text", onClick: () => vm.clearPreferences()}, "Clear preferences")),
]) ])
]) ])
]); ]);
} }
} }
function externalLink(t, href, label) { function externalLink(t, href, label) {
return t.a({href, target: "_blank", rel: "noopener noreferrer"}, label); return t.a({href, target: "_blank", rel: "noopener noreferrer"}, label);
} }

View File

@ -23,51 +23,51 @@ import {LoadServerPolicyViewModel} from "./policy/LoadServerPolicyViewModel.js";
import {Platform} from "./Platform.js"; import {Platform} from "./Platform.js";
export class RootViewModel extends ViewModel { export class RootViewModel extends ViewModel {
constructor(options) { constructor(options) {
super(options); super(options);
this.link = null; this.link = null;
this.openLinkViewModel = null; this.openLinkViewModel = null;
this.createLinkViewModel = null; this.createLinkViewModel = null;
this.loadServerPolicyViewModel = null; this.loadServerPolicyViewModel = null;
this.preferences.on("canClear", () => { this.preferences.on("canClear", () => {
this.emitChange(); this.emitChange();
}); });
} }
_updateChildVMs(oldLink) { _updateChildVMs(oldLink) {
if (this.link) { if (this.link) {
this.createLinkViewModel = null; this.createLinkViewModel = null;
if (!oldLink || !oldLink.equals(this.link)) { if (!oldLink || !oldLink.equals(this.link)) {
this.openLinkViewModel = new OpenLinkViewModel(this.childOptions({ this.openLinkViewModel = new OpenLinkViewModel(this.childOptions({
link: this.link, link: this.link,
clients: createClients(), clients: createClients(),
})); }));
} }
} else { } else {
this.openLinkViewModel = null; this.openLinkViewModel = null;
this.createLinkViewModel = new CreateLinkViewModel(this.childOptions()); this.createLinkViewModel = new CreateLinkViewModel(this.childOptions());
} }
this.emitChange(); this.emitChange();
} }
updateHash(hash) { updateHash(hash) {
if (hash.startsWith("#/policy/")) { if (hash.startsWith("#/policy/")) {
const server = hash.substr(9); const server = hash.substr(9);
this.loadServerPolicyViewModel = new LoadServerPolicyViewModel(this.childOptions({server})); this.loadServerPolicyViewModel = new LoadServerPolicyViewModel(this.childOptions({server}));
this.loadServerPolicyViewModel.load(); this.loadServerPolicyViewModel.load();
} else { } else {
const oldLink = this.link; const oldLink = this.link;
this.link = Link.parse(hash); this.link = Link.parse(hash);
this._updateChildVMs(oldLink); this._updateChildVMs(oldLink);
} }
} }
clearPreferences() { clearPreferences() {
this.preferences.clear(); this.preferences.clear();
this._updateChildVMs(); this._updateChildVMs();
} }
get hasPreferences() { get hasPreferences() {
return this.preferences.canClear; return this.preferences.canClear;
} }
} }

View File

@ -19,31 +19,31 @@ import {PreviewView} from "../preview/PreviewView.js";
import {copyButton} from "../utils/copy.js"; import {copyButton} from "../utils/copy.js";
export class CreateLinkView extends TemplateView { export class CreateLinkView extends TemplateView {
render(t, vm) { render(t, vm) {
const link = t.a({href: vm => vm.linkUrl}, vm => vm.linkUrl); const link = t.a({href: vm => vm.linkUrl}, vm => vm.linkUrl);
return t.div({className: "CreateLinkView card"}, [ return t.div({className: "CreateLinkView card"}, [
t.h1("Create shareable links to Matrix rooms, users or messages without being tied to any app"), t.h1("Create shareable links to Matrix rooms, users or messages without being tied to any app"),
t.form({action: "#", onSubmit: evt => this._onSubmit(evt)}, [ t.form({action: "#", onSubmit: evt => this._onSubmit(evt)}, [
t.div(t.input({ t.div(t.input({
className: "fullwidth large", className: "fullwidth large",
type: "text", type: "text",
name: "identifier", name: "identifier",
required: true, required: true,
placeholder: "#room:example.com, @user:example.com", placeholder: "#room:example.com, @user:example.com",
onChange: evt => this._onIdentifierChange(evt) onChange: evt => this._onIdentifierChange(evt)
})), })),
t.div(t.input({className: "primary fullwidth icon link", type: "submit", value: "Create link"})) t.div(t.input({className: "primary fullwidth icon link", type: "submit", value: "Create link"}))
]), ]),
]); ]);
} }
_onSubmit(evt) { _onSubmit(evt) {
evt.preventDefault(); evt.preventDefault();
const form = evt.target; const form = evt.target;
const {identifier} = form.elements; const {identifier} = form.elements;
this.value.createLink(identifier.value); this.value.createLink(identifier.value);
identifier.value = ""; identifier.value = "";
} }
_onIdentifierChange(evt) { _onIdentifierChange(evt) {
const inputField = evt.target; const inputField = evt.target;

View File

@ -19,11 +19,11 @@ import {PreviewViewModel} from "../preview/PreviewViewModel.js";
import {Link} from "../Link.js"; import {Link} from "../Link.js";
export class CreateLinkViewModel extends ViewModel { export class CreateLinkViewModel extends ViewModel {
constructor(options) { constructor(options) {
super(options); super(options);
this._link = null; this._link = null;
this.previewViewModel = null; this.previewViewModel = null;
} }
validateIdentifier(identifier) { validateIdentifier(identifier) {
return Link.validateIdentifier(identifier); return Link.validateIdentifier(identifier);

View File

@ -21,18 +21,18 @@ import {Preferences} from "./Preferences.js";
import {guessApplicablePlatforms} from "./Platform.js"; import {guessApplicablePlatforms} from "./Platform.js";
export async function main(container) { export async function main(container) {
const vm = new RootViewModel({ const vm = new RootViewModel({
request: xhrRequest, request: xhrRequest,
openLink: url => location.href = url, openLink: url => location.href = url,
platforms: guessApplicablePlatforms(navigator.userAgent, navigator.platform), platforms: guessApplicablePlatforms(navigator.userAgent, navigator.platform),
preferences: new Preferences(window.localStorage), preferences: new Preferences(window.localStorage),
origin: location.origin, origin: location.origin,
}); });
vm.updateHash(decodeURIComponent(location.hash)); vm.updateHash(decodeURIComponent(location.hash));
window.__rootvm = vm; window.__rootvm = vm;
const view = new RootView(vm); const view = new RootView(vm);
container.appendChild(view.mount()); container.appendChild(view.mount());
window.addEventListener('hashchange', () => { window.addEventListener('hashchange', () => {
vm.updateHash(decodeURIComponent(location.hash)); vm.updateHash(decodeURIComponent(location.hash));
}); });
} }

View File

@ -18,50 +18,50 @@ import {TemplateView} from "../utils/TemplateView.js";
import {ClientView} from "./ClientView.js"; import {ClientView} from "./ClientView.js";
export class ClientListView extends TemplateView { export class ClientListView extends TemplateView {
render(t, vm) { render(t, vm) {
return t.mapView(vm => vm.clientViewModel, () => { return t.mapView(vm => vm.clientViewModel, () => {
if (vm.clientViewModel) { if (vm.clientViewModel) {
return new ContinueWithClientView(vm); return new ContinueWithClientView(vm);
} else { } else {
return new AllClientsView(vm); return new AllClientsView(vm);
} }
}); });
} }
} }
class AllClientsView extends TemplateView { class AllClientsView extends TemplateView {
render(t, vm) { render(t, vm) {
return t.div({className: "ClientListView"}, [ return t.div({className: "ClientListView"}, [
t.h2("Choose an app to continue"), t.h2("Choose an app to continue"),
t.map(vm => vm.clientList, (clientList, t) => { t.map(vm => vm.clientList, (clientList, t) => {
return t.div({className: "list"}, clientList.map(clientViewModel => { return t.div({className: "list"}, clientList.map(clientViewModel => {
return t.view(new ClientView(clientViewModel)); return t.view(new ClientView(clientViewModel));
})); }));
}), }),
t.div(t.label([ t.div(t.label([
t.input({ t.input({
type: "checkbox", type: "checkbox",
checked: vm.showUnsupportedPlatforms, checked: vm.showUnsupportedPlatforms,
onChange: evt => vm.showUnsupportedPlatforms = evt.target.checked, onChange: evt => vm.showUnsupportedPlatforms = evt.target.checked,
}), }),
"Show apps not available on my platform" "Show apps not available on my platform"
])), ])),
t.div(t.label({className: "filterOption"}, [ t.div(t.label({className: "filterOption"}, [
t.input({ t.input({
type: "checkbox", type: "checkbox",
checked: vm.showExperimental, checked: vm.showExperimental,
onChange: evt => vm.showExperimental = evt.target.checked, onChange: evt => vm.showExperimental = evt.target.checked,
}), }),
"Show experimental apps" "Show experimental apps"
])), ])),
]); ]);
} }
} }
class ContinueWithClientView extends TemplateView { class ContinueWithClientView extends TemplateView {
render(t, vm) { render(t, vm) {
return t.div({className: "ClientListView"}, [ return t.div({className: "ClientListView"}, [
t.div({className: "list"}, t.view(new ClientView(vm.clientViewModel))) t.div({className: "list"}, t.view(new ClientView(vm.clientViewModel)))
]); ]);
} }
} }

View File

@ -20,70 +20,70 @@ import {ClientViewModel} from "./ClientViewModel.js";
import {ViewModel} from "../utils/ViewModel.js"; import {ViewModel} from "../utils/ViewModel.js";
export class ClientListViewModel extends ViewModel { export class ClientListViewModel extends ViewModel {
constructor(options) { constructor(options) {
super(options); super(options);
const {clients, client, link} = options; const {clients, client, link} = options;
this._clients = clients; this._clients = clients;
this._link = link; this._link = link;
this.clientList = null; this.clientList = null;
this._showExperimental = false; this._showExperimental = false;
this._showUnsupportedPlatforms = false; this._showUnsupportedPlatforms = false;
this._filterClients(); this._filterClients();
this.clientViewModel = null; this.clientViewModel = null;
if (client) { if (client) {
this._pickClient(client); this._pickClient(client);
} }
} }
get showUnsupportedPlatforms() { get showUnsupportedPlatforms() {
return this._showUnsupportedPlatforms; return this._showUnsupportedPlatforms;
} }
get showExperimental() { get showExperimental() {
return this._showExperimental; return this._showExperimental;
} }
set showUnsupportedPlatforms(enabled) { set showUnsupportedPlatforms(enabled) {
this._showUnsupportedPlatforms = enabled; this._showUnsupportedPlatforms = enabled;
this._filterClients(); this._filterClients();
} }
set showExperimental(enabled) { set showExperimental(enabled) {
this._showExperimental = enabled; this._showExperimental = enabled;
this._filterClients(); this._filterClients();
} }
_filterClients() { _filterClients() {
const clientVMs = this._clients.filter(client => { const clientVMs = this._clients.filter(client => {
const platformMaturities = this.platforms.map(p => client.getMaturity(p)); const platformMaturities = this.platforms.map(p => client.getMaturity(p));
const isStable = platformMaturities.includes(Maturity.Stable) || platformMaturities.includes(Maturity.Beta); const isStable = platformMaturities.includes(Maturity.Stable) || platformMaturities.includes(Maturity.Beta);
const isSupported = client.platforms.some(p => this.platforms.includes(p)); const isSupported = client.platforms.some(p => this.platforms.includes(p));
if (!this._showExperimental && !isStable) { if (!this._showExperimental && !isStable) {
return false; return false;
} }
if (!this._showUnsupportedPlatforms && !isSupported) { if (!this._showUnsupportedPlatforms && !isSupported) {
return false; return false;
} }
return true; return true;
}).map(client => new ClientViewModel(this.childOptions({ }).map(client => new ClientViewModel(this.childOptions({
client, client,
link: this._link, link: this._link,
pickClient: client => this._pickClient(client) pickClient: client => this._pickClient(client)
}))); })));
const preferredClientVMs = clientVMs.filter(c => c.hasPreferredWebInstance); const preferredClientVMs = clientVMs.filter(c => c.hasPreferredWebInstance);
const otherClientVMs = clientVMs.filter(c => !c.hasPreferredWebInstance); const otherClientVMs = clientVMs.filter(c => !c.hasPreferredWebInstance);
this.clientList = preferredClientVMs.concat(otherClientVMs); this.clientList = preferredClientVMs.concat(otherClientVMs);
this.emitChange(); this.emitChange();
} }
_pickClient(client) { _pickClient(client) {
this.clientViewModel = this.clientList.find(vm => vm.clientId === client.id); this.clientViewModel = this.clientList.find(vm => vm.clientId === client.id);
this.clientViewModel.pick(this); this.clientViewModel.pick(this);
this.emitChange(); this.emitChange();
} }
showAll() { showAll() {
this.clientViewModel = null; this.clientViewModel = null;
this.emitChange(); this.emitChange();
} }
} }

View File

@ -19,11 +19,11 @@ import {copy} from "../utils/copy.js";
import {text, tag} from "../utils/html.js"; import {text, tag} from "../utils/html.js";
function formatPlatforms(platforms) { function formatPlatforms(platforms) {
return platforms.reduce((str, p, i, all) => { return platforms.reduce((str, p, i, all) => {
const first = i === 0; const first = i === 0;
const last = i === all.length - 1; const last = i === all.length - 1;
return str + (first ? "" : last ? " & " : ", ") + p; return str + (first ? "" : last ? " & " : ", ") + p;
}, ""); }, "");
} }
function renderInstructions(parts) { function renderInstructions(parts) {
@ -38,46 +38,46 @@ function renderInstructions(parts) {
export class ClientView extends TemplateView { export class ClientView extends TemplateView {
render(t, vm) { render(t, vm) {
return t.div({className: {"ClientView": true, "isPreferred": vm => vm.hasPreferredWebInstance}}, [ return t.div({className: {"ClientView": true, "isPreferred": vm => vm.hasPreferredWebInstance}}, [
... vm.hasPreferredWebInstance ? [t.div({className: "hostedBanner"}, vm.hostedByBannerLabel)] : [], ... vm.hasPreferredWebInstance ? [t.div({className: "hostedBanner"}, vm.hostedByBannerLabel)] : [],
t.div({className: "header"}, [ t.div({className: "header"}, [
t.div({className: "description"}, [ t.div({className: "description"}, [
t.h3(vm.name), t.h3(vm.name),
t.p([vm.description, " ", t.a({ t.p([vm.description, " ", t.a({
href: vm.homepage, href: vm.homepage,
target: "_blank", target: "_blank",
rel: "noopener noreferrer" rel: "noopener noreferrer"
}, "Learn more")]), }, "Learn more")]),
t.p({className: "platforms"}, formatPlatforms(vm.availableOnPlatformNames)), t.p({className: "platforms"}, formatPlatforms(vm.availableOnPlatformNames)),
]), ]),
t.img({className: "clientIcon", src: vm.iconUrl}) t.img({className: "clientIcon", src: vm.iconUrl})
]), ]),
t.mapView(vm => vm.stage, stage => { t.mapView(vm => vm.stage, stage => {
switch (stage) { switch (stage) {
case "open": return new OpenClientView(vm); case "open": return new OpenClientView(vm);
case "install": return new InstallClientView(vm); case "install": return new InstallClientView(vm);
} }
}), }),
]); ]);
} }
} }
class OpenClientView extends TemplateView { class OpenClientView extends TemplateView {
render(t, vm) { render(t, vm) {
return t.div({className: "OpenClientView"}, [ return t.div({className: "OpenClientView"}, [
...vm.openActions.map(a => renderAction(t, a)), ...vm.openActions.map(a => renderAction(t, a)),
showBack(t, vm), showBack(t, vm),
]); ]);
} }
} }
class InstallClientView extends TemplateView { class InstallClientView extends TemplateView {
render(t, vm) { render(t, vm) {
const children = []; const children = [];
const textInstructions = vm.textInstructions; const textInstructions = vm.textInstructions;
if (textInstructions) { if (textInstructions) {
const copyButton = t.button({ const copyButton = t.button({
className: "copy", className: "copy",
title: "Copy instructions", title: "Copy instructions",
@ -91,25 +91,25 @@ class InstallClientView extends TemplateView {
} }
} }
}); });
children.push(t.p({className: "instructions"}, renderInstructions(textInstructions).concat(copyButton))); children.push(t.p({className: "instructions"}, renderInstructions(textInstructions).concat(copyButton)));
} }
const actions = t.div({className: "actions"}, vm.installActions.map(a => renderAction(t, a))); const actions = t.div({className: "actions"}, vm.installActions.map(a => renderAction(t, a)));
children.push(actions); children.push(actions);
if (vm.showDeepLinkInInstall) { if (vm.showDeepLinkInInstall) {
const openItHere = t.a({ const openItHere = t.a({
rel: "noopener noreferrer", rel: "noopener noreferrer",
href: vm.openActions[0].url, href: vm.openActions[0].url,
onClick: () => vm.openActions[0].activated(), onClick: () => vm.openActions[0].activated(),
}, "open it here"); }, "open it here");
children.push(t.p([`If you already have ${vm.name} installed, you can `, openItHere, "."])) children.push(t.p([`If you already have ${vm.name} installed, you can `, openItHere, "."]))
} }
children.push(showBack(t, vm)); children.push(showBack(t, vm));
return t.div({className: "InstallClientView"}, children); return t.div({className: "InstallClientView"}, children);
} }
} }
function showBack(t, vm) { function showBack(t, vm) {

View File

@ -27,28 +27,28 @@ function getMatchingPlatforms(client, supportedPlatforms) {
} }
export class ClientViewModel extends ViewModel { export class ClientViewModel extends ViewModel {
constructor(options) { constructor(options) {
super(options); super(options);
const {client, link, pickClient} = options; const {client, link, pickClient} = options;
this._client = client; this._client = client;
this._link = link; this._link = link;
this._pickClient = pickClient; this._pickClient = pickClient;
// to provide "choose other client" button after calling pick() // to provide "choose other client" button after calling pick()
this._clientListViewModel = null; this._clientListViewModel = null;
this._update(); this._update();
} }
_update() { _update() {
const matchingPlatforms = getMatchingPlatforms(this._client, this.platforms); const matchingPlatforms = getMatchingPlatforms(this._client, this.platforms);
this._webPlatform = matchingPlatforms.find(p => isWebPlatform(p)); this._webPlatform = matchingPlatforms.find(p => isWebPlatform(p));
this._nativePlatform = matchingPlatforms.find(p => !isWebPlatform(p)); this._nativePlatform = matchingPlatforms.find(p => !isWebPlatform(p));
const preferredPlatform = matchingPlatforms.find(p => p === this.preferences.platform); const preferredPlatform = matchingPlatforms.find(p => p === this.preferences.platform);
this._proposedPlatform = preferredPlatform || this._nativePlatform || this._webPlatform; this._proposedPlatform = preferredPlatform || this._nativePlatform || this._webPlatform;
this.openActions = this._createOpenActions(); this.openActions = this._createOpenActions();
this.installActions = this._createInstallActions(); this.installActions = this._createInstallActions();
this._clientCanIntercept = !!(this._nativePlatform && this._client.canInterceptMatrixToLinks(this._nativePlatform)); this._clientCanIntercept = !!(this._nativePlatform && this._client.canInterceptMatrixToLinks(this._nativePlatform));
this._showOpen = this.openActions.length && !this._clientCanIntercept; this._showOpen = this.openActions.length && !this._clientCanIntercept;
} }
// these are only shown in the open stage // these are only shown in the open stage
@ -93,40 +93,40 @@ export class ClientViewModel extends ViewModel {
} }
// these are only shown in the install stage // these are only shown in the install stage
_createInstallActions() { _createInstallActions() {
let actions = []; let actions = [];
if (this._nativePlatform) { if (this._nativePlatform) {
const nativeActions = (this._client.getInstallLinks(this._nativePlatform) || []).map(installLink => { const nativeActions = (this._client.getInstallLinks(this._nativePlatform) || []).map(installLink => {
return { return {
label: installLink.getDescription(this._nativePlatform), label: installLink.getDescription(this._nativePlatform),
url: installLink.createInstallURL(this._link), url: installLink.createInstallURL(this._link),
kind: installLink.channelId, kind: installLink.channelId,
primary: true, primary: true,
activated: () => this.preferences.setClient(this._client.id, this._nativePlatform), activated: () => this.preferences.setClient(this._client.id, this._nativePlatform),
}; };
}); });
actions.push(...nativeActions); actions.push(...nativeActions);
} }
if (this._webPlatform) { if (this._webPlatform) {
const webDeepLink = this._client.getDeepLink(this._webPlatform, this._link); const webDeepLink = this._client.getDeepLink(this._webPlatform, this._link);
if (webDeepLink) { if (webDeepLink) {
const webLabel = this.hasPreferredWebInstance ? const webLabel = this.hasPreferredWebInstance ?
`Open on ${this._client.getPreferredWebInstance(this._link)}` : `Open on ${this._client.getPreferredWebInstance(this._link)}` :
`Continue in your browser`; `Continue in your browser`;
actions.push({ actions.push({
label: webLabel, label: webLabel,
url: webDeepLink, url: webDeepLink,
kind: "open-in-web", kind: "open-in-web",
activated: () => { activated: () => {
if (!this.hasPreferredWebInstance) { if (!this.hasPreferredWebInstance) {
this.preferences.setClient(this._client.id, this._webPlatform); this.preferences.setClient(this._client.id, this._webPlatform);
} }
}, },
}); });
} }
} }
return actions; return actions;
} }
get hasPreferredWebInstance() { get hasPreferredWebInstance() {
// also check there is a web platform that matches the platforms the user is on (mobile or desktop web) // also check there is a web platform that matches the platforms the user is on (mobile or desktop web)
@ -150,17 +150,17 @@ export class ClientViewModel extends ViewModel {
return this._client.homepage; return this._client.homepage;
} }
get identifier() { get identifier() {
return this._link.identifier; return this._link.identifier;
} }
get description() { get description() {
return this._client.description; return this._client.description;
} }
get clientId() { get clientId() {
return this._client.id; return this._client.id;
} }
get name() { get name() {
return this._client.name; return this._client.name;
@ -174,44 +174,44 @@ export class ClientViewModel extends ViewModel {
return this._showOpen ? "open" : "install"; return this._showOpen ? "open" : "install";
} }
get textInstructions() { get textInstructions() {
let instructions = this._client.getLinkInstructions(this._proposedPlatform, this._link); let instructions = this._client.getLinkInstructions(this._proposedPlatform, this._link);
if (instructions && !Array.isArray(instructions)) { if (instructions && !Array.isArray(instructions)) {
instructions = [instructions]; instructions = [instructions];
} }
return instructions; return instructions;
} }
get copyString() { get copyString() {
return this._client.getCopyString(this._proposedPlatform, this._link); return this._client.getCopyString(this._proposedPlatform, this._link);
} }
get showDeepLinkInInstall() { get showDeepLinkInInstall() {
// we can assume this._nativePlatform as this._clientCanIntercept already checks it // we can assume this._nativePlatform as this._clientCanIntercept already checks it
return this._clientCanIntercept && !!this._client.getDeepLink(this._nativePlatform, this._link); return this._clientCanIntercept && !!this._client.getDeepLink(this._nativePlatform, this._link);
} }
get availableOnPlatformNames() { get availableOnPlatformNames() {
const platforms = this._client.platforms; const platforms = this._client.platforms;
const textPlatforms = []; const textPlatforms = [];
const hasWebPlatform = platforms.some(p => isWebPlatform(p)); const hasWebPlatform = platforms.some(p => isWebPlatform(p));
if (hasWebPlatform) { if (hasWebPlatform) {
textPlatforms.push("Web"); textPlatforms.push("Web");
} }
const desktopPlatforms = platforms.filter(p => isDesktopPlatform(p)); const desktopPlatforms = platforms.filter(p => isDesktopPlatform(p));
if (desktopPlatforms.length === 1) { if (desktopPlatforms.length === 1) {
textPlatforms.push(desktopPlatforms[0]); textPlatforms.push(desktopPlatforms[0]);
} else { } else {
textPlatforms.push("Desktop"); textPlatforms.push("Desktop");
} }
if (platforms.includes(Platform.Android)) { if (platforms.includes(Platform.Android)) {
textPlatforms.push("Android"); textPlatforms.push("Android");
} }
if (platforms.includes(Platform.iOS)) { if (platforms.includes(Platform.iOS)) {
textPlatforms.push("iOS"); textPlatforms.push("iOS");
} }
return textPlatforms; return textPlatforms;
} }
pick(clientListViewModel) { pick(clientListViewModel) {
this._clientListViewModel = clientListViewModel; this._clientListViewModel = clientListViewModel;

View File

@ -20,14 +20,14 @@ import {PreviewView} from "../preview/PreviewView.js";
import {ServerConsentView} from "./ServerConsentView.js"; import {ServerConsentView} from "./ServerConsentView.js";
export class OpenLinkView extends TemplateView { export class OpenLinkView extends TemplateView {
render(t, vm) { render(t, vm) {
return t.div({className: "OpenLinkView card"}, [ return t.div({className: "OpenLinkView card"}, [
t.mapView(vm => vm.previewViewModel, previewVM => previewVM ? t.mapView(vm => vm.previewViewModel, previewVM => previewVM ?
new ShowLinkView(vm) : new ShowLinkView(vm) :
new ServerConsentView(vm.serverConsentViewModel) new ServerConsentView(vm.serverConsentViewModel)
), ),
]); ]);
} }
} }
class ShowLinkView extends TemplateView { class ShowLinkView extends TemplateView {

View File

@ -23,21 +23,21 @@ import {getLabelForLinkKind} from "../Link.js";
import {orderedUnique} from "../utils/unique.js"; import {orderedUnique} from "../utils/unique.js";
export class OpenLinkViewModel extends ViewModel { export class OpenLinkViewModel extends ViewModel {
constructor(options) { constructor(options) {
super(options); super(options);
const {clients, link} = options; const {clients, link} = options;
this._link = link; this._link = link;
this._clients = clients; this._clients = clients;
this.serverConsentViewModel = null; this.serverConsentViewModel = null;
this.previewViewModel = null; this.previewViewModel = null;
this.clientsViewModel = null; this.clientsViewModel = null;
this.previewLoading = false; this.previewLoading = false;
if (this.preferences.homeservers === null) { if (this.preferences.homeservers === null) {
this._showServerConsent(); this._showServerConsent();
} else { } else {
this._showLink(); this._showLink();
} }
} }
_showServerConsent() { _showServerConsent() {
let servers = []; let servers = [];
@ -67,24 +67,24 @@ export class OpenLinkViewModel extends ViewModel {
link: this._link, link: this._link,
consentedServers: this.preferences.homeservers consentedServers: this.preferences.homeservers
})); }));
this.previewLoading = true; this.previewLoading = true;
this.emitChange(); this.emitChange();
await this.previewViewModel.load(); await this.previewViewModel.load();
this.previewLoading = false; this.previewLoading = false;
this.emitChange(); this.emitChange();
} }
get previewDomain() { get previewDomain() {
return this.previewViewModel?.domain; return this.previewViewModel?.domain;
} }
get previewFailed() { get previewFailed() {
return this.previewViewModel?.failed; return this.previewViewModel?.failed;
} }
get showClientsLabel() { get showClientsLabel() {
return getLabelForLinkKind(this._link.kind); return getLabelForLinkKind(this._link.kind);
} }
changeServer() { changeServer() {
this.previewViewModel = null; this.previewViewModel = null;

View File

@ -27,7 +27,7 @@ export class ServerConsentView extends TemplateView {
className: "text", className: "text",
onClick: () => vm.continueWithoutConsent(this._askEveryTimeChecked) onClick: () => vm.continueWithoutConsent(this._askEveryTimeChecked)
}, "continue without a preview"); }, "continue without a preview");
return t.div({className: "ServerConsentView"}, [ return t.div({className: "ServerConsentView"}, [
t.p([ t.p([
"Preview this link using the ", "Preview this link using the ",
t.strong(vm => vm.selectedServer || "…"), t.strong(vm => vm.selectedServer || "…"),
@ -56,7 +56,7 @@ export class ServerConsentView extends TemplateView {
]) ])
]) ])
]); ]);
} }
_onSubmit(evt) { _onSubmit(evt) {
evt.preventDefault(); evt.preventDefault();

View File

@ -22,13 +22,13 @@ import {getLabelForLinkKind} from "../Link.js";
import {orderedUnique} from "../utils/unique.js"; import {orderedUnique} from "../utils/unique.js";
export class ServerConsentViewModel extends ViewModel { export class ServerConsentViewModel extends ViewModel {
constructor(options) { constructor(options) {
super(options); super(options);
this.servers = options.servers; this.servers = options.servers;
this.done = options.done; this.done = options.done;
this.selectedServer = this.servers[0]; this.selectedServer = this.servers[0];
this.showSelectServer = false; this.showSelectServer = false;
} }
setShowServers() { setShowServers() {
this.showSelectServer = true; this.showSelectServer = true;

View File

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import {Maturity, Platform, LinkKind, import {Maturity, Platform, LinkKind,
FDroidLink, AppleStoreLink, PlayStoreLink, WebsiteLink} from "../types.js"; FDroidLink, AppleStoreLink, PlayStoreLink, WebsiteLink} from "../types.js";
const trustedWebInstances = [ const trustedWebInstances = [
"app.element.io", // first one is the default one "app.element.io", // first one is the default one
@ -29,69 +29,69 @@ const trustedWebInstances = [
* Information on how to deep link to a given matrix client. * Information on how to deep link to a given matrix client.
*/ */
export class Element { export class Element {
get id() { return "element.io"; } get id() { return "element.io"; }
get platforms() { get platforms() {
return [ return [
Platform.Android, Platform.iOS, Platform.Android, Platform.iOS,
Platform.Windows, Platform.macOS, Platform.Linux, Platform.Windows, Platform.macOS, Platform.Linux,
Platform.DesktopWeb Platform.DesktopWeb
]; ];
} }
get icon() { return "images/client-icons/element.svg"; } get icon() { return "images/client-icons/element.svg"; }
get appleAssociatedAppId() { return "7J4U792NQT.im.vector.app"; } get appleAssociatedAppId() { return "7J4U792NQT.im.vector.app"; }
get name() {return "Element"; } get name() {return "Element"; }
get description() { return 'Fully-featured Matrix client, used by millions.'; } get description() { return 'Fully-featured Matrix client, used by millions.'; }
get homepage() { return "https://element.io"; } get homepage() { return "https://element.io"; }
get author() { return "Element"; } get author() { return "Element"; }
getMaturity(platform) { return Maturity.Stable; } getMaturity(platform) { return Maturity.Stable; }
getDeepLink(platform, link) { getDeepLink(platform, link) {
let fragmentPath; let fragmentPath;
switch (link.kind) { switch (link.kind) {
case LinkKind.User: case LinkKind.User:
fragmentPath = `user/${link.identifier}`; fragmentPath = `user/${link.identifier}`;
break; break;
case LinkKind.Room: case LinkKind.Room:
fragmentPath = `room/${link.identifier}`; fragmentPath = `room/${link.identifier}`;
break; break;
case LinkKind.Group: case LinkKind.Group:
fragmentPath = `group/${link.identifier}`; fragmentPath = `group/${link.identifier}`;
break; break;
case LinkKind.Event: case LinkKind.Event:
fragmentPath = `room/${link.identifier}/${link.eventId}`; fragmentPath = `room/${link.identifier}/${link.eventId}`;
break; break;
} }
const isWebPlatform = platform === Platform.DesktopWeb || platform === Platform.MobileWeb; const isWebPlatform = platform === Platform.DesktopWeb || platform === Platform.MobileWeb;
if (isWebPlatform || platform === Platform.iOS) { if (isWebPlatform || platform === Platform.iOS) {
let instanceHost = trustedWebInstances[0]; let instanceHost = trustedWebInstances[0];
// we use app.element.io which iOS will intercept, but it likely won't intercept any other trusted instances // we use app.element.io which iOS will intercept, but it likely won't intercept any other trusted instances
// so only use a preferred web instance for true web links. // so only use a preferred web instance for true web links.
if (isWebPlatform && trustedWebInstances.includes(link.webInstances[this.id])) { if (isWebPlatform && trustedWebInstances.includes(link.webInstances[this.id])) {
instanceHost = link.webInstances[this.id]; instanceHost = link.webInstances[this.id];
} }
return `https://${instanceHost}/#/${fragmentPath}`; return `https://${instanceHost}/#/${fragmentPath}`;
} else if (platform === Platform.Linux || platform === Platform.Windows || platform === Platform.macOS) { } else if (platform === Platform.Linux || platform === Platform.Windows || platform === Platform.macOS) {
return `element://vector/webapp/#/${fragmentPath}`; return `element://vector/webapp/#/${fragmentPath}`;
} else { } else {
return `element://${fragmentPath}`; return `element://${fragmentPath}`;
} }
} }
getLinkInstructions(platform, link) {} getLinkInstructions(platform, link) {}
getCopyString(platform, link) {} getCopyString(platform, link) {}
getInstallLinks(platform) { getInstallLinks(platform) {
switch (platform) { switch (platform) {
case Platform.iOS: return [new AppleStoreLink('vector', 'id1083446067')]; case Platform.iOS: return [new AppleStoreLink('vector', 'id1083446067')];
case Platform.Android: return [new PlayStoreLink('im.vector.app'), new FDroidLink('im.vector.app')]; case Platform.Android: return [new PlayStoreLink('im.vector.app'), new FDroidLink('im.vector.app')];
default: return [new WebsiteLink("https://element.io/get-started")]; default: return [new WebsiteLink("https://element.io/get-started")];
} }
} }
canInterceptMatrixToLinks(platform) { canInterceptMatrixToLinks(platform) {
return platform === Platform.Android; return platform === Platform.Android;
} }
getPreferredWebInstance(link) { getPreferredWebInstance(link) {
const idx = trustedWebInstances.indexOf(link.webInstances[this.id]) const idx = trustedWebInstances.indexOf(link.webInstances[this.id])

View File

@ -20,22 +20,22 @@ import {Maturity, Platform, LinkKind, FlathubLink} from "../types.js";
* Information on how to deep link to a given matrix client. * Information on how to deep link to a given matrix client.
*/ */
export class Fractal { export class Fractal {
get id() { return "fractal"; } get id() { return "fractal"; }
get name() { return "Fractal"; } get name() { return "Fractal"; }
get icon() { return "images/client-icons/fractal.png"; } get icon() { return "images/client-icons/fractal.png"; }
get author() { return "Daniel Garcia Moreno"; } get author() { return "Daniel Garcia Moreno"; }
get homepage() { return "https://gitlab.gnome.org/GNOME/fractal"; } get homepage() { return "https://gitlab.gnome.org/GNOME/fractal"; }
get platforms() { return [Platform.Linux]; } get platforms() { return [Platform.Linux]; }
get description() { return 'Fractal is a Matrix Client written in Rust.'; } get description() { return 'Fractal is a Matrix Client written in Rust.'; }
getMaturity(platform) { return Maturity.Beta; } getMaturity(platform) { return Maturity.Beta; }
getDeepLink(platform, link) {} getDeepLink(platform, link) {}
canInterceptMatrixToLinks(platform) { return false; } canInterceptMatrixToLinks(platform) { return false; }
getLinkInstructions(platform, link) { getLinkInstructions(platform, link) {
if (link.kind === LinkKind.User || link.kind === LinkKind.Room) { if (link.kind === LinkKind.User || link.kind === LinkKind.Room) {
return "Click the '+' button in the top right and paste the identifier"; return "Click the '+' button in the top right and paste the identifier";
} }
} }
getCopyString(platform, link) { getCopyString(platform, link) {
if (link.kind === LinkKind.User || link.kind === LinkKind.Room) { if (link.kind === LinkKind.User || link.kind === LinkKind.Room) {
@ -43,7 +43,7 @@ export class Fractal {
} }
} }
getInstallLinks(platform) { getInstallLinks(platform) {
if (platform === Platform.Linux) { if (platform === Platform.Linux) {
return [new FlathubLink("org.gnome.Fractal")]; return [new FlathubLink("org.gnome.Fractal")];
} }

View File

@ -20,49 +20,49 @@ import {Maturity, Platform, LinkKind, FlathubLink, style} from "../types.js";
* Information on how to deep link to a given matrix client. * Information on how to deep link to a given matrix client.
*/ */
export class Nheko { export class Nheko {
get id() { return "nheko"; } get id() { return "nheko"; }
get name() { return "Nheko"; } get name() { return "Nheko"; }
get icon() { return "images/client-icons/nheko.svg"; } get icon() { return "images/client-icons/nheko.svg"; }
get author() { return "mujx, red_sky, deepbluev7, Konstantinos Sideris"; } get author() { return "mujx, red_sky, deepbluev7, Konstantinos Sideris"; }
get homepage() { return "https://github.com/Nheko-Reborn/nheko"; } get homepage() { return "https://github.com/Nheko-Reborn/nheko"; }
get platforms() { return [Platform.Windows, Platform.macOS, Platform.Linux]; } get platforms() { return [Platform.Windows, Platform.macOS, Platform.Linux]; }
get description() { return 'A native desktop app for Matrix that feels more like a mainstream chat app.'; } get description() { return 'A native desktop app for Matrix that feels more like a mainstream chat app.'; }
getMaturity(platform) { return Maturity.Beta; } getMaturity(platform) { return Maturity.Beta; }
getDeepLink(platform, link) { getDeepLink(platform, link) {
if (platform === Platform.Linux || platform === Platform.Windows) { if (platform === Platform.Linux || platform === Platform.Windows) {
let identifier = encodeURIComponent(link.identifier.substring(1)); let identifier = encodeURIComponent(link.identifier.substring(1));
let isRoomid = link.identifier.substring(0, 1) === '!'; let isRoomid = link.identifier.substring(0, 1) === '!';
let fragmentPath; let fragmentPath;
switch (link.kind) { switch (link.kind) {
case LinkKind.User: case LinkKind.User:
fragmentPath = `u/${identifier}?action=chat`; fragmentPath = `u/${identifier}?action=chat`;
break; break;
case LinkKind.Room: case LinkKind.Room:
case LinkKind.Event: case LinkKind.Event:
if (isRoomid) if (isRoomid)
fragmentPath = `roomid/${identifier}`; fragmentPath = `roomid/${identifier}`;
else else
fragmentPath = `r/${identifier}`; fragmentPath = `r/${identifier}`;
if (link.kind === LinkKind.Event) if (link.kind === LinkKind.Event)
fragmentPath += `/e/${encodeURIComponent(link.eventId.substring(1))}`; fragmentPath += `/e/${encodeURIComponent(link.eventId.substring(1))}`;
fragmentPath += '?action=join'; fragmentPath += '?action=join';
fragmentPath += link.servers.map(server => `&via=${encodeURIComponent(server)}`).join(''); fragmentPath += link.servers.map(server => `&via=${encodeURIComponent(server)}`).join('');
break; break;
case LinkKind.Group: case LinkKind.Group:
return; return;
} }
return `matrix:${fragmentPath}`; return `matrix:${fragmentPath}`;
} }
} }
canInterceptMatrixToLinks(platform) { return false; } canInterceptMatrixToLinks(platform) { return false; }
getLinkInstructions(platform, link) { getLinkInstructions(platform, link) {
switch (link.kind) { switch (link.kind) {
case LinkKind.User: return [`Type `, style.code(`/invite ${link.identifier}`)]; case LinkKind.User: return [`Type `, style.code(`/invite ${link.identifier}`)];
case LinkKind.Room: return [`Type `, style.code(`/join ${link.identifier}`)]; case LinkKind.Room: return [`Type `, style.code(`/join ${link.identifier}`)];
} }
} }
getCopyString(platform, link) { getCopyString(platform, link) {
switch (link.kind) { switch (link.kind) {
@ -71,7 +71,7 @@ export class Nheko {
} }
} }
getInstallLinks(platform) { getInstallLinks(platform) {
if (platform === Platform.Linux) { if (platform === Platform.Linux) {
return [new FlathubLink("io.github.NhekoReborn.Nheko")]; return [new FlathubLink("io.github.NhekoReborn.Nheko")];
} }

View File

@ -20,23 +20,23 @@ import {Maturity, Platform, LinkKind, WebsiteLink, style} from "../types.js";
* Information on how to deep link to a given matrix client. * Information on how to deep link to a given matrix client.
*/ */
export class Weechat { export class Weechat {
get id() { return "weechat"; } get id() { return "weechat"; }
get name() { return "Weechat"; } get name() { return "Weechat"; }
get icon() { return "images/client-icons/weechat.svg"; } get icon() { return "images/client-icons/weechat.svg"; }
get author() { return "Poljar"; } get author() { return "Poljar"; }
get homepage() { return "https://github.com/poljar/weechat-matrix"; } get homepage() { return "https://github.com/poljar/weechat-matrix"; }
get platforms() { return [Platform.Windows, Platform.macOS, Platform.Linux]; } get platforms() { return [Platform.Windows, Platform.macOS, Platform.Linux]; }
get description() { return 'Command-line Matrix interface using Weechat.'; } get description() { return 'Command-line Matrix interface using Weechat.'; }
getMaturity(platform) { return Maturity.Beta; } getMaturity(platform) { return Maturity.Beta; }
getDeepLink(platform, link) {} getDeepLink(platform, link) {}
canInterceptMatrixToLinks(platform) { return false; } canInterceptMatrixToLinks(platform) { return false; }
getLinkInstructions(platform, link) { getLinkInstructions(platform, link) {
switch (link.kind) { switch (link.kind) {
case LinkKind.User: return [`Type `, style.code(`/invite ${link.identifier}`)]; case LinkKind.User: return [`Type `, style.code(`/invite ${link.identifier}`)];
case LinkKind.Room: return [`Type `, style.code(`/join ${link.identifier}`)]; case LinkKind.Room: return [`Type `, style.code(`/join ${link.identifier}`)];
} }
} }
getCopyString(platform, link) { getCopyString(platform, link) {
switch (link.kind) { switch (link.kind) {
@ -45,7 +45,7 @@ export class Weechat {
} }
} }
getInstallLinks(platform) {} getInstallLinks(platform) {}
getPreferredWebInstance(link) {} getPreferredWebInstance(link) {}
} }

View File

@ -23,13 +23,13 @@ import {Tensor} from "./Tensor.js";
import {Fluffychat} from "./Fluffychat.js"; import {Fluffychat} from "./Fluffychat.js";
export function createClients() { export function createClients() {
return [ return [
new Element(), new Element(),
new Weechat(), new Weechat(),
new Nheko(), new Nheko(),
new Fractal(), new Fractal(),
new Quaternion(), new Quaternion(),
new Tensor(), new Tensor(),
new Fluffychat(), new Fluffychat(),
]; ];
} }

View File

@ -21,8 +21,8 @@ export {Platform} from "../Platform.js";
export class AppleStoreLink { export class AppleStoreLink {
constructor(org, appId) { constructor(org, appId) {
this._org = org; this._org = org;
this._appId = appId; this._appId = appId;
} }
createInstallURL(link) { createInstallURL(link) {
@ -40,7 +40,7 @@ export class AppleStoreLink {
export class PlayStoreLink { export class PlayStoreLink {
constructor(appId) { constructor(appId) {
this._appId = appId; this._appId = appId;
} }
createInstallURL(link) { createInstallURL(link) {
@ -58,7 +58,7 @@ export class PlayStoreLink {
export class FDroidLink { export class FDroidLink {
constructor(appId) { constructor(appId) {
this._appId = appId; this._appId = appId;
} }
createInstallURL(link) { createInstallURL(link) {
@ -94,7 +94,7 @@ export class FlathubLink {
export class WebsiteLink { export class WebsiteLink {
constructor(url) { constructor(url) {
this._url = url; this._url = url;
} }
createInstallURL(link) { createInstallURL(link) {

View File

@ -17,10 +17,10 @@ limitations under the License.
import {TemplateView} from "../utils/TemplateView.js"; import {TemplateView} from "../utils/TemplateView.js";
export class LoadServerPolicyView extends TemplateView { export class LoadServerPolicyView extends TemplateView {
render(t, vm) { render(t, vm) {
return t.div({className: "LoadServerPolicyView card"}, [ return t.div({className: "LoadServerPolicyView card"}, [
t.div({className: {spinner: true, hidden: vm => !vm.loading}}), t.div({className: {spinner: true, hidden: vm => !vm.loading}}),
t.h2(vm => vm.message) t.h2(vm => vm.message)
]); ]);
} }
} }

View File

@ -18,12 +18,12 @@ import {ViewModel} from "../utils/ViewModel.js";
import {resolveServer} from "../preview/HomeServer.js"; import {resolveServer} from "../preview/HomeServer.js";
export class LoadServerPolicyViewModel extends ViewModel { export class LoadServerPolicyViewModel extends ViewModel {
constructor(options) { constructor(options) {
super(options); super(options);
this.server = options.server; this.server = options.server;
this.message = `Looking up ${this.server} privacy policy…`; this.message = `Looking up ${this.server} privacy policy…`;
this.loading = false; this.loading = false;
} }
async load() { async load() {
this.loading = true; this.loading = true;

View File

@ -20,61 +20,76 @@ function noTrailingSlash(url) {
export async function resolveServer(request, baseURL) { export async function resolveServer(request, baseURL) {
baseURL = noTrailingSlash(baseURL); baseURL = noTrailingSlash(baseURL);
if (!baseURL.startsWith("http://") && !baseURL.startsWith("https://")) { if (!baseURL.startsWith("http://") && !baseURL.startsWith("https://")) {
baseURL = `https://${baseURL}`; baseURL = `https://${baseURL}`;
} }
{ {
const {status, body} = await request(`${baseURL}/.well-known/matrix/client`, {method: "GET"}).response(); try {
if (status === 200) { const {status, body} = await request(`${baseURL}/.well-known/matrix/client`, {method: "GET"}).response();
const proposedBaseURL = body?.['m.homeserver']?.base_url; if (status === 200) {
if (typeof proposedBaseURL === "string") { const proposedBaseURL = body?.['m.homeserver']?.base_url;
baseURL = noTrailingSlash(proposedBaseURL); if (typeof proposedBaseURL === "string") {
} baseURL = noTrailingSlash(proposedBaseURL);
} }
} }
{ } catch (e) {
const {status} = await request(`${baseURL}/_matrix/client/versions`, {method: "GET"}).response(); console.warn("Failed to fetch ${baseURL}/.well-known/matrix/client", e);
if (status !== 200) { }
throw new Error(`Invalid versions response from ${baseURL}`); }
} {
} const {status} = await request(`${baseURL}/_matrix/client/versions`, {method: "GET"}).response();
return new HomeServer(request, baseURL); if (status !== 200) {
throw new Error(`Invalid versions response from ${baseURL}`);
}
}
return new HomeServer(request, baseURL);
} }
export class HomeServer { export class HomeServer {
constructor(request, baseURL) { constructor(request, baseURL) {
this._request = request; this._request = request;
this.baseURL = baseURL; this.baseURL = baseURL;
} }
async getUserProfile(userId) { async getUserProfile(userId) {
const {body} = await this._request(`${this.baseURL}/_matrix/client/r0/profile/${encodeURIComponent(userId)}`).response(); const {body} = await this._request(`${this.baseURL}/_matrix/client/r0/profile/${encodeURIComponent(userId)}`).response();
return body; return body;
} }
async findPublicRoomById(roomId) { // MSC3266 implementation
const {body, status} = await this._request(`${this.baseURL}/_matrix/client/r0/directory/list/room/${encodeURIComponent(roomId)}`).response(); async getRoomSummary(roomIdOrAlias, viaServers) {
if (status !== 200 || body.visibility !== "public") { let query;
return; if (viaServers.length > 0) {
} query = "?" + viaServers.map(server => `via=${encodeURIComponent(server)}`).join('&');
let nextBatch; }
do { const {body, status} = await this._request(`${this.baseURL}/_matrix/client/unstable/im.nheko.summary/rooms/${encodeURIComponent(roomIdOrAlias)}/summary${query}`).response();
const queryParams = encodeQueryParams({limit: 10000, since: nextBatch}); if (status !== 200) return;
const {body, status} = await this._request(`${this.baseURL}/_matrix/client/r0/publicRooms?${queryParams}`).response(); return body;
nextBatch = body.next_batch; }
const publicRoom = body.chunk.find(c => c.room_id === roomId);
if (publicRoom) {
return publicRoom;
}
} while (nextBatch);
}
async getRoomIdFromAlias(alias) { async findPublicRoomById(roomId) {
const {status, body} = await this._request(`${this.baseURL}/_matrix/client/r0/directory/room/${encodeURIComponent(alias)}`).response(); const {body, status} = await this._request(`${this.baseURL}/_matrix/client/r0/directory/list/room/${encodeURIComponent(roomId)}`).response();
if (status === 200) { if (status !== 200 || body.visibility !== "public") {
return body.room_id; return;
} }
} let nextBatch;
do {
const queryParams = encodeQueryParams({limit: 10000, since: nextBatch});
const {body, status} = await this._request(`${this.baseURL}/_matrix/client/r0/publicRooms?${queryParams}`).response();
nextBatch = body.next_batch;
const publicRoom = body.chunk.find(c => c.room_id === roomId);
if (publicRoom) {
return publicRoom;
}
} while (nextBatch);
}
async getRoomIdFromAlias(alias) {
const {status, body} = await this._request(`${this.baseURL}/_matrix/client/r0/directory/room/${encodeURIComponent(alias)}`).response();
if (status === 200) {
return body.room_id;
}
}
async getPrivacyPolicyUrl(lang = "en") { async getPrivacyPolicyUrl(lang = "en") {
const headers = new Map(); const headers = new Map();
@ -94,7 +109,7 @@ export class HomeServer {
} }
} }
mxcUrlThumbnail(url, width, height, method) { mxcUrlThumbnail(url, width, height, method) {
const parts = parseMxcUrl(url); const parts = parseMxcUrl(url);
if (parts) { if (parts) {
const [serverName, mediaId] = parts; const [serverName, mediaId] = parts;

View File

@ -43,7 +43,7 @@ class LoadingPreviewView extends TemplateView {
} }
class LoadedPreviewView extends TemplateView { class LoadedPreviewView extends TemplateView {
render(t, vm) { render(t, vm) {
const avatar = t.map(vm => vm.avatarUrl, (avatarUrl, t) => { const avatar = t.map(vm => vm.avatarUrl, (avatarUrl, t) => {
if (avatarUrl) { if (avatarUrl) {
return t.img({className: "avatar", src: avatarUrl}); return t.img({className: "avatar", src: avatarUrl});
@ -51,12 +51,12 @@ class LoadedPreviewView extends TemplateView {
return t.div({className: "defaultAvatar"}); return t.div({className: "defaultAvatar"});
} }
}); });
return t.div([ return t.div({className: vm.isSpaceRoom ? "mxSpace" : undefined}, [
t.div({className: "avatarContainer"}, avatar), t.div({className: "avatarContainer"}, avatar),
t.h1(vm => vm.name), t.h1(vm => vm.name),
t.p({className: {identifier: true, hidden: vm => !vm.identifier}}, vm => vm.identifier), t.p({className: {identifier: true, hidden: vm => !vm.identifier}}, vm => vm.identifier),
t.div({className: {memberCount: true, hidden: vm => !vm.memberCount}}, t.p([vm => vm.memberCount, " members"])), t.div({className: {memberCount: true, hidden: vm => !vm.memberCount}}, t.p([vm => vm.memberCount, " members"])),
t.p({className: {topic: true, hidden: vm => !vm.topic}}, [vm => vm.topic]), t.p({className: {topic: true, hidden: vm => !vm.topic}}, [vm => vm.topic]),
]); ]);
} }
} }

View File

@ -21,92 +21,101 @@ import {ClientListViewModel} from "../open/ClientListViewModel.js";
import {ClientViewModel} from "../open/ClientViewModel.js"; import {ClientViewModel} from "../open/ClientViewModel.js";
export class PreviewViewModel extends ViewModel { export class PreviewViewModel extends ViewModel {
constructor(options) { constructor(options) {
super(options); super(options);
const { link, consentedServers } = options; const { link, consentedServers } = options;
this._link = link; this._link = link;
this._consentedServers = consentedServers; this._consentedServers = consentedServers;
this.loading = false; this.loading = false;
this.name = this._link.identifier; this.name = this._link.identifier;
this.avatarUrl = null; this.avatarUrl = null;
this.identifier = null; this.identifier = null;
this.memberCount = null; this.memberCount = null;
this.topic = null; this.topic = null;
this.domain = null; this.domain = null;
this.failed = false; this.failed = false;
} this.isSpaceRoom = false;
}
async load() { async load() {
const {kind} = this._link; const {kind} = this._link;
const supportsPreview = kind === LinkKind.User || kind === LinkKind.Room || kind === LinkKind.Event; const supportsPreview = kind === LinkKind.User || kind === LinkKind.Room || kind === LinkKind.Event;
if (supportsPreview) { if (supportsPreview) {
this.loading = true; this.loading = true;
this.emitChange(); this.emitChange();
for (const server of this._consentedServers) { for (const server of this._consentedServers) {
try { try {
const homeserver = await resolveServer(this.request, server); const homeserver = await resolveServer(this.request, server);
switch (this._link.kind) { switch (this._link.kind) {
case LinkKind.User: case LinkKind.User:
await this._loadUserPreview(homeserver, this._link.identifier); await this._loadUserPreview(homeserver, this._link.identifier);
break; break;
case LinkKind.Room: case LinkKind.Room:
case LinkKind.Event: case LinkKind.Event:
await this._loadRoomPreview(homeserver, this._link); await this._loadRoomPreview(homeserver, this._link);
break; break;
} }
// assume we're done if nothing threw // assume we're done if nothing threw
this.domain = server; this.domain = server;
this.loading = false; this.loading = false;
this.emitChange(); this.emitChange();
return; return;
} catch (err) { } catch (err) {
continue; continue;
} }
} }
} }
this.loading = false; this.loading = false;
this._setNoPreview(this._link); this._setNoPreview(this._link);
if (this._consentedServers.length && supportsPreview) { if (this._consentedServers.length && supportsPreview) {
this.domain = this._consentedServers[this._consentedServers.length - 1]; this.domain = this._consentedServers[this._consentedServers.length - 1];
this.failed = true; this.failed = true;
} }
this.emitChange(); this.emitChange();
} }
get hasTopic() { return this._link.kind === LinkKind.Room; } get hasTopic() { return this._link.kind === LinkKind.Room; }
get hasMemberCount() { return this.hasTopic; } get hasMemberCount() { return this.hasTopic; }
async _loadUserPreview(homeserver, userId) { async _loadUserPreview(homeserver, userId) {
const profile = await homeserver.getUserProfile(userId); const profile = await homeserver.getUserProfile(userId);
this.name = profile.displayname || userId; this.name = profile.displayname || userId;
this.avatarUrl = profile.avatar_url ? this.avatarUrl = profile.avatar_url ?
homeserver.mxcUrlThumbnail(profile.avatar_url, 64, 64, "crop") : homeserver.mxcUrlThumbnail(profile.avatar_url, 64, 64, "crop") :
null; null;
this.identifier = userId; this.identifier = userId;
} }
async _loadRoomPreview(homeserver, link) { async _loadRoomPreview(homeserver, link) {
let publicRoom; let publicRoom;
if (link.identifierKind === IdentifierKind.RoomId) { if (link.identifierKind === IdentifierKind.RoomId || link.identifierKind === IdentifierKind.RoomAlias) {
publicRoom = await homeserver.findPublicRoomById(link.identifier); publicRoom = await homeserver.getRoomSummary(link.identifier, link.servers);
} else if (link.identifierKind === IdentifierKind.RoomAlias) { }
const roomId = await homeserver.getRoomIdFromAlias(link.identifier);
if (roomId) { if (!publicRoom) {
publicRoom = await homeserver.findPublicRoomById(roomId); if (link.identifierKind === IdentifierKind.RoomId) {
} publicRoom = await homeserver.findPublicRoomById(link.identifier);
} } else if (link.identifierKind === IdentifierKind.RoomAlias) {
this.name = publicRoom?.name || publicRoom?.canonical_alias || link.identifier; const roomId = await homeserver.getRoomIdFromAlias(link.identifier);
this.avatarUrl = publicRoom?.avatar_url ? if (roomId) {
homeserver.mxcUrlThumbnail(publicRoom.avatar_url, 64, 64, "crop") : publicRoom = await homeserver.findPublicRoomById(roomId);
null; }
this.memberCount = publicRoom?.num_joined_members; }
this.topic = publicRoom?.topic; }
this.identifier = publicRoom?.canonical_alias || link.identifier;
this.name = publicRoom?.name || publicRoom?.canonical_alias || link.identifier;
this.avatarUrl = publicRoom?.avatar_url ?
homeserver.mxcUrlThumbnail(publicRoom.avatar_url, 64, 64, "crop") :
null;
this.memberCount = publicRoom?.num_joined_members;
this.topic = publicRoom?.topic;
this.identifier = publicRoom?.canonical_alias || link.identifier;
this.isSpaceRoom = publicRoom?.room_type === "m.space";
if (this.identifier === this.name) { if (this.identifier === this.name) {
this.identifier = null; this.identifier = null;
} }
} }
_setNoPreview(link) { _setNoPreview(link) {
this.name = link.identifier; this.name = link.identifier;