Add login GUI #9

Manually merged
cadence merged 10 commits from login into princess 2020-10-21 09:07:38 +00:00
12 changed files with 324 additions and 58 deletions

View file

@ -34,9 +34,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

2
package-lock.json generated
View file

@ -1,5 +1,5 @@
{
"name": "cosc212-assignment-1",
"name": "carbon",
"version": "1.0.0",
"lockfileVersion": 1,
"requires": true,

67
spec.js
View file

@ -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",
},
];

View file

@ -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
textarea(placeholder="Send a message..." autocomplete="off").c-chat-input__textarea#c-chat-textarea

View file

@ -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}`))
}
@ -282,7 +284,6 @@ class Timeline extends Subscribable {
if (eventData.type === "m.room.message" && eventData.content["m.relates_to"] && eventData.content["m.relates_to"].rel_type === "m.replace") {
const replaces = eventData.content["m.relates_to"].event_id
if (this.map.has(replaces)) {
console.log(eventData)
const event = this.map.get(replaces)
event.data.content = eventData.content["m.new_content"]
event.setEdited(eventData.origin_server_ts)

148
src/js/login.js Normal file
View file

@ -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()

9
src/js/main.js Normal file
View file

@ -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")
}

View file

@ -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() {

View file

@ -121,4 +121,6 @@ function syncLoop() {
store.activeGroup.set(store.groups.get("directs").value())
syncLoop()
if (lsm.get("access_token")) {
syncLoop()
}

View file

@ -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")

View file

@ -33,6 +33,7 @@ $out-width: $base-width + rooms.$list-width
&__container
width: $out-width
padding: $icon-padding
box-sizing: border-box
.c-group
display: flex

81
src/sass/login.sass Normal file
View file

@ -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