diff --git a/README.md b/README.md index 07ca920..2f2006b 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,9 @@ Carbon is currently _technically_ usable as a chat app, but is very early in development. These important features still need to be implemented: -- Login GUI - Unreads - Chat history +- Typing indicators - Formatting - Emojis - Reactions diff --git a/package-lock.json b/package-lock.json index e5806d8..94cc52b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "cosc212-assignment-1", + "name": "carbon", "version": "1.0.0", "lockfileVersion": 1, "requires": true, diff --git a/spec.js b/spec.js index 38bb96e..450f7cc 100644 --- a/spec.js +++ b/spec.js @@ -2,121 +2,136 @@ module.exports = [ { type: "file", source: "/assets/fonts/whitney-500.woff", - target: "/static/whitney-500.woff" + target: "/static/whitney-500.woff", }, { type: "file", source: "/assets/fonts/whitney-400.woff", - target: "/static/whitney-400.woff" + target: "/static/whitney-400.woff", + }, + { + type: "js", + source: "/js/main.js", + target: "/static/main.js", }, { type: "js", source: "/js/basic.js", - target: "/static/basic.js" + target: "/static/basic.js", }, { type: "js", source: "/js/groups.js", - target: "/static/groups.js" + target: "/static/groups.js", }, { type: "js", source: "/js/chat-input.js", - target: "/static/chat-input.js" + target: "/static/chat-input.js", }, { type: "js", source: "/js/room-picker.js", - target: "/static/room-picker.js" + target: "/static/room-picker.js", }, { type: "js", source: "/js/store/store.js", - target: "/static/store/store.js" + target: "/static/store/store.js", }, { type: "js", source: "/js/store/Subscribable.js", - target: "/static/store/Subscribable.js" + target: "/static/store/Subscribable.js", }, { type: "js", source: "/js/store/SubscribeValue.js", - target: "/static/store/SubscribeValue.js" + target: "/static/store/SubscribeValue.js", }, { type: "js", source: "/js/store/SubscribeMapList.js", - target: "/static/store/SubscribeMapList.js" + target: "/static/store/SubscribeMapList.js", }, { type: "js", source: "/js/store/SubscribeSet.js", - target: "/static/store/SubscribeSet.js" + target: "/static/store/SubscribeSet.js", }, { type: "js", source: "/js/sync/sync.js", - target: "/static/sync/sync.js" + target: "/static/sync/sync.js", }, { type: "js", source: "/js/lsm.js", - target: "/static/lsm.js" + target: "/static/lsm.js", }, { type: "js", source: "/js/Timeline.js", - target: "/static/Timeline.js" + target: "/static/Timeline.js", }, { type: "js", source: "/js/Anchor.js", - target: "/static/Anchor.js" + target: "/static/Anchor.js", }, { type: "js", source: "/js/chat.js", - target: "/static/chat.js" + target: "/static/chat.js", }, { type: "js", source: "/js/functions.js", - target: "/static/functions.js" + target: "/static/functions.js", + }, + { + type: "js", + source: "/js/login.js", + target: "/static/login.js", }, { type: "file", source: "/assets/fonts/whitney-500.woff", - target: "/static/whitney-500.woff" + target: "/static/whitney-500.woff", }, { type: "file", source: "/assets/icons/directs.svg", - target: "/static/directs.svg" + target: "/static/directs.svg", }, { type: "file", source: "/assets/icons/channels.svg", - target: "/static/channels.svg" + target: "/static/channels.svg", }, { type: "file", source: "/assets/icons/join-event.svg", - target: "/static/join-event.svg" + target: "/static/join-event.svg", }, { type: "sass", source: "/sass/main.sass", - target: "/static/main.css" + target: "/static/main.css", + }, + { + type: "sass", + source: "/sass/login.sass", + target: "/static/login.css", }, { type: "pug", source: "/home.pug", - target: "/index.html" + target: "/index.html", }, { type: "pug", source: "/login.pug", - target: "/login.html" - } -] + target: "/login/index.html", + }, +]; diff --git a/src/home.pug b/src/home.pug index 68bbd21..09a00c7 100644 --- a/src/home.pug +++ b/src/home.pug @@ -33,18 +33,14 @@ doctype html html head meta(charset="utf-8") + title Carbon // var static = !{JSON.stringify([...static.entries()].reduce((a, c) => (a[c[0]] = getRelative(c[1]), a), {}))} script | var staticFiles = new Map( != JSON.stringify([...static.keys()].map(k => [k, getStatic(k)])) | ) link(rel="stylesheet" type="text/css" href=getStatic("/sass/main.sass")) - script(type="module" src=getStatic("/js/groups.js")) - script(type="module" src=getStatic("/js/chat-input.js")) - script(type="module" src=getStatic("/js/room-picker.js")) - script(type="module" src=getStatic("/js/sync/sync.js")) - script(type="module" src=getStatic("/js/chat.js")) - title Carbon + script(type="module" src=getStatic("/js/main.js")) body main.main .c-groups @@ -56,4 +52,4 @@ html .c-chat__messages#c-chat-messages .c-chat__inner#c-chat .c-chat-input - textarea(placeholder="Send a message..." autocomplete="off").c-chat-input__textarea#c-chat-textarea \ No newline at end of file + textarea(placeholder="Send a message..." autocomplete="off").c-chat-input__textarea#c-chat-textarea diff --git a/src/js/Timeline.js b/src/js/Timeline.js index 94e77bf..3e2a0b6 100644 --- a/src/js/Timeline.js +++ b/src/js/Timeline.js @@ -81,6 +81,8 @@ class Event extends ElemJS { } else { this.child(ejs("i").text("left the room")) } + } else if (this.data.type === "m.room.encrypted") { + this.child(ejs("i").text("Carbon does not yet support encrypted messages.")) } else { this.child(ejs("i").text(`Unsupported event type ${this.data.type}`)) } diff --git a/src/js/login.js b/src/js/login.js new file mode 100644 index 0000000..de32932 --- /dev/null +++ b/src/js/login.js @@ -0,0 +1,148 @@ +import {q, ElemJS, ejs} from $to_relative "/js/basic.js" + +const password = q("#password") +const homeserver = q("#homeserver") + +class Username extends ElemJS { + constructor() { + super(q("#username")) + + this.on("change", this.updateServer.bind(this)) + } + + isValid() { + return !!this.element.value.match(/^@?[a-z0-9._=\/-]+(?::[a-zA-Z0-9.:\[\]-]+)?$/) + } + + getUsername() { + return this.element.value.match(/^@?([a-z0-9._=\/-]+)/)[1] + } + + getServer() { + const server = this.element.value.match(/^@?[a-z0-9._=\?-]+:([a-zA-Z0-9.:\[\]-]+)$/) + if (server && server[1]) return server[1] + else return null + } + + updateServer() { + if (!this.isValid()) return + if (this.getServer()) homeserver.value = this.getServer() + } +} + +const username = new Username() + +class Feedback extends ElemJS { + constructor() { + super(q("#feedback")) + this.loading = false + this.loadingIcon = ejs("span").class("loading-icon") + this.messageSpan = ejs("span") + this.child(this.messageSpan) + } + + setLoading(state) { + if (this.loading && !state) { + this.loadingIcon.remove() + } else if (!this.loading && state) { + this.childAt(0, this.loadingIcon) + } + this.loading = state + } + + message(content) { + if (content) { + this.class("form-feedback") + } else { + this.removeClass("form-feedback") + } + this.messageSpan.text(content) + } +} + +const feedback = new Feedback() + +class Form extends ElemJS { + constructor() { + super(q("#form")) + + this.processing = false + + this.on("submit", this.submit.bind(this)) + } + + async submit() { + if (this.processing) return + this.processing = true + if (!username.isValid()) return this.cancel("Username is not valid.") + + // Resolve homeserver address + let currentAddress = homeserver.value + let ok = false + while (!ok) { + if (!currentAddress.match(/^https?:\/\//)) currentAddress = "https://" + currentAddress + currentAddress = currentAddress.replace(/\/*$/, "") + this.status(`Looking up homeserver... trying ${currentAddress}`) + try { + // check if we found the actual matrix server + try { + const versions = await fetch(`${currentAddress}/_matrix/client/versions`).then(res => res.json()) + if (Array.isArray(versions.versions)) { + ok = true + break + } + } catch (e) {} + // find the next matrix server in the chain + const root = await fetch(`${currentAddress}/.well-known/matrix/client`).then(res => res.json()) + let nextAddress = root["m.homeserver"].base_url + nextAddress = nextAddress.replace(/\/*$/, "") + if (currentAddress === nextAddress) { + ok = true + } + currentAddress = nextAddress + } catch (e) { + return this.cancel(`Failed to look up server ${currentAddress}`) + } + } + + // Request access token + this.status("Logging in...") + const root = await fetch(`${currentAddress}/_matrix/client/r0/login`, { + method: "POST", + body: JSON.stringify({ + type: "m.login.password", + user: username.getUsername(), + password: password.value + }) + }).then(res => res.json()) + + if (!root.access_token) { + if (root.error) { + this.cancel(`Server said: ${root.error}`) + } else { + this.cancel("Login mysteriously failed.") + console.error(root) + } + return + } + + localStorage.setItem("mx_user_id", root.user_id) + localStorage.setItem("domain", currentAddress) + localStorage.setItem("access_token", root.access_token) + + location.assign("./") + } + + status(message) { + feedback.setLoading(true) + feedback.message(message) + } + + cancel(message) { + this.processing = false + feedback.setLoading(false) + feedback.message(message) + } +} + +const form = new Form() diff --git a/src/js/main.js b/src/js/main.js new file mode 100644 index 0000000..98dd38b --- /dev/null +++ b/src/js/main.js @@ -0,0 +1,9 @@ +import $to_relative "/js/groups.js" +import $to_relative "/js/chat-input.js" +import $to_relative "/js/room-picker.js" +import $to_relative "/js/sync/sync.js" +import $to_relative "/js/chat.js" + +if (!localStorage.getItem("access_token")) { + location.assign("./login") +} diff --git a/src/js/room-picker.js b/src/js/room-picker.js index 8e696de..9fbbece 100644 --- a/src/js/room-picker.js +++ b/src/js/room-picker.js @@ -108,10 +108,12 @@ class Room extends ElemJS { getIcon() { const avatar = this.data.state.events.find(e => e.type === "m.room.avatar") if (avatar) { - return resolveMxc(avatar.content.url || avatar.content.avatar_url, 32, "crop") - } else { - return null + const url = avatar.content.url || avatar.content.avatar_url + if (url) { + return resolveMxc(url, 32, "crop") + } } + return null } isDirect() { diff --git a/src/js/sync/sync.js b/src/js/sync/sync.js index bbd6b11..f338e4d 100644 --- a/src/js/sync/sync.js +++ b/src/js/sync/sync.js @@ -121,4 +121,6 @@ function syncLoop() { store.activeGroup.set(store.groups.get("directs").value()) -syncLoop() +if (lsm.get("access_token")) { + syncLoop() +} diff --git a/src/login.pug b/src/login.pug index 105b6bd..470ee82 100644 --- a/src/login.pug +++ b/src/login.pug @@ -1,21 +1,32 @@ doctype html html - head - meta(charset="utf-8") - link(rel="stylesheet" type="text/css" href=getStatic("/sass/main.sass")) - title Carbon - body - main.main - form - div - label(for="login") Username - input(type="text" name="login" autocomplete="username" placeholder="example:matrix.org" required)#login - div - label(for="password") Password - input(type="text" name="password" autocomplete="current-password" required)#password - div + head + meta(charset="utf-8") + title Carbon + meta(name="viewport" content="width=device-width, initial-scale=1") + link(rel="stylesheet" type="text/css" href=getStatic("/sass/login.sass")) + script(type="module" src=getStatic("/js/login.js")) + + body + main.main + .center-login-container + h1 Welcome to Carbon! + form.login-form(method="post" onsubmit="return false")#form + .data-input + .form-input-container + label(for="username") Username + input(type="text" name="username" autocomplete="username" placeholder="@username:server.tld" pattern="^@?[a-z0-9._=/-]+(?::[a-zA-Z0-9.:\\[\\]-]+)?$" required)#username + + .form-input-container + label(for="password") Password + input(name="password" autocomplete="current-password" type="password" required)#password + + .form-input-container + label(for="homeserver") Homeserver + input(type="text" name="homeserver" value="matrix.org" placeholder="matrix.org" required)#homeserver + + #feedback + + .form-input-container + input(type="submit" value="Log in")#submit - label(for="homeserver") Homeserver - input(type="text" name="homeserver" value="matrix.org" required)#homeserver - div - input(type="submit" value="Login") diff --git a/src/sass/login.sass b/src/sass/login.sass new file mode 100644 index 0000000..e4820ee --- /dev/null +++ b/src/sass/login.sass @@ -0,0 +1,81 @@ +@use "./base" +@use "./colors.sass" as c + +.main + justify-content: center + align-items: center + +.center-login-container + display: flex + flex-flow: column + justify-content: center + align-items: center + width: min(100vw, 450px) + padding: max(1rem,3vw) 2rem + margin: 8px + box-shadow: 0px 2px 10px c.$darkest + background-color: c.$darker + border-radius: 5px + +.login-form + align-items: center + flex: 1 1 auto + width: 100% + display: flex + justify-content: space-around + flex-flow: column + +.data-input + width: 100% + +.form-input-container + width: 100% + display: flex + flex-direction: column + margin: 1em 0 + +.form-feedback + width: 100% + margin: -0.5em 0 -0.8em + +@keyframes spin + 0% + transform: rotate(0deg) + 100% + transform: rotate(180deg) + +.loading-icon + display: inline-block + background-color: #ccc + width: 12px + height: 12px + margin-right: 6px + animation: spin 0.7s infinite + +input, button + font-family: inherit + font-size: 17px + background-color: c.$mild + color: #eee + width: 100% + border-radius: 5px + box-sizing: border-box + transition: background-color 0.15s ease-out, border-color 0.15s ease-out + padding: 4px 9px + border: 0px + +input[type="text"],input[type="password"] + border: 3px solid transparent + margin: 0.4em 0px + + &:hover, &:focus + border-color: c.$milder + +button, input[type="submit"] + padding: 7px + + &:hover + background-color: c.$milder + +label + font-size: 18px