Merge branch 'princess' into fix/login-improvements
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing

I cannot merge, hopefull this is fine
This commit is contained in:
Bad 2020-10-30 12:55:19 +01:00
commit 6224cde132
13 changed files with 539 additions and 269 deletions

4
.gitignore vendored
View file

@ -288,6 +288,10 @@ modules.xml
# End of https://www.toptal.com/developers/gitignore/api/node,vscode,webstorm,webstorm+all # End of https://www.toptal.com/developers/gitignore/api/node,vscode,webstorm,webstorm+all
# Emacs
*~
\#*#
# Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option)
/build/ /build/

View file

@ -9,7 +9,8 @@ const babel = require("@babel/core")
const fetch = require("node-fetch") const fetch = require("node-fetch")
const chalk = require("chalk") const chalk = require("chalk")
const hint = require("jshint").JSHINT const hint = require("jshint").JSHINT
const browserify = require('browserify') const browserify = require("browserify")
const {Transform} = require("stream")
process.chdir(pj(__dirname, "src")) process.chdir(pj(__dirname, "src"))
@ -17,10 +18,10 @@ const buildDir = "../build"
const validationQueue = [] const validationQueue = []
const validationHost = os.hostname() === "future" ? "http://localhost:8888/" : "http://validator.w3.org/nu/" const validationHost = os.hostname() === "future" ? "http://localhost:8888/" : "http://validator.w3.org/nu/"
const static_files = new Map() const staticFiles = new Map()
const links = new Map() const links = new Map()
const sources = new Map() const sources = new Map()
const pugLocals = {static: static_files, links} const pugLocals = {static: staticFiles, links}
const spec = require("./spec.js") const spec = require("./spec.js")
@ -95,6 +96,7 @@ function runHint(filename, source) {
globals: ["console", "URLSearchParams", "staticFiles"], globals: ["console", "URLSearchParams", "staticFiles"],
browser: true, browser: true,
asi: true, asi: true,
node: true
}) })
const result = hint.data() const result = hint.data()
let problems = 0 let problems = 0
@ -127,33 +129,52 @@ function runHint(filename, source) {
async function addFile(sourcePath, targetPath) { async function addFile(sourcePath, targetPath) {
const contents = await fs.promises.readFile(pj(".", sourcePath), {encoding: null}); const contents = await fs.promises.readFile(pj(".", sourcePath), {encoding: null});
static_files.set(sourcePath, `${targetPath}?static=${hash(contents)}`) staticFiles.set(sourcePath, `${targetPath}?static=${hash(contents)}`)
await fs.promises.writeFile(pj(buildDir, targetPath), contents) await fs.promises.writeFile(pj(buildDir, targetPath), contents)
} }
async function loadJS(sourcePath, targetPath) { async function loadJS(sourcePath, targetPath) {
let content = await fs.promises.readFile(pj(".", sourcePath), {encoding: "utf8"}) let content = await fs.promises.readFile(pj(".", sourcePath), {encoding: "utf8"})
sources.set(sourcePath, content); sources.set(sourcePath, content)
static_files.set(sourcePath, `${targetPath}?static=${hash(content)}`) staticFiles.set(sourcePath, `${targetPath}?static=${hash(content)}`)
} }
async function addJS(sourcePath, targetPath) { async function addJS(sourcePath, targetPath) {
let content = sources.get(sourcePath) let content = sources.get(sourcePath)
// resolve imports to hashed paths
content = content.replace(/\$to_relative "([^"]+)"/g, function(_, file) {
if (!static_files.get(file)) throw new Error(`Tried to relative import ${file} from ${sourcePath}, but import not found`)
return '"' + getRelative(targetPath, static_files.get(file)) + '"'
})
runHint(sourcePath, content) runHint(sourcePath, content)
await fs.promises.writeFile(pj(buildDir, targetPath), content) await fs.promises.writeFile(pj(buildDir, targetPath), content)
} }
async function addBundle(sourcePath, targetPath) { async function addBundle(sourcePath, targetPath) {
await browserify() const content = await new Promise(resolve => {
browserify()
.add(pj(".", sourcePath)) .add(pj(".", sourcePath))
.bundle() .transform(file => {
.pipe(fs.createWriteStream(pj(buildDir, targetPath))); let content = ""
static_files.set(sourcePath, targetPath) const transform = new Transform({
transform(chunk, encoding, callback) {
content += chunk.toString()
callback(null, chunk)
}
})
transform.on("finish", () => {
const relativePath = path.relative(process.cwd(), file).replace(/^\/*/, "/")
runHint(relativePath, content)
})
return transform
})
.bundle((err, res) => {
if (err) {
delete err.stream
throw err // Quit; problem parsing file to bundle
}
resolve(res)
})
})
const writer = fs.promises.writeFile(pj(buildDir, targetPath), content)
staticFiles.set(sourcePath, `${targetPath}?static=${hash(content)}`)
runHint(sourcePath, content)
await writer
} }
async function addSass(sourcePath, targetPath) { async function addSass(sourcePath, targetPath) {
@ -167,7 +188,7 @@ async function addSass(sourcePath, targetPath) {
if (!(name instanceof sass.types.String)) { if (!(name instanceof sass.types.String)) {
throw "$name: expected a string" throw "$name: expected a string"
} }
const result = getRelative(targetPath, static_files.get(name.getValue())) const result = getRelative(targetPath, staticFiles.get(name.getValue()))
if (typeof result === "string") { if (typeof result === "string") {
return new sass.types.String(result) return new sass.types.String(result)
} else { } else {
@ -176,8 +197,8 @@ async function addSass(sourcePath, targetPath) {
} }
} }
}).css; }).css;
static_files.set(sourcePath, `${targetPath}?static=${hash(renderedCSS)}`) staticFiles.set(sourcePath, `${targetPath}?static=${hash(renderedCSS)}`)
await validate(sourcePath, renderedCSS, "css") validate(sourcePath, renderedCSS, "css")
await fs.promises.writeFile(pj(buildDir, targetPath), renderedCSS) await fs.promises.writeFile(pj(buildDir, targetPath), renderedCSS)
} }
@ -186,17 +207,17 @@ async function addPug(sourcePath, targetPath) {
return getRelative(targetPath, staticTarget) return getRelative(targetPath, staticTarget)
} }
function getStatic(target) { function getStatic(target) {
return getRelativeHere(static_files.get(target)) return getRelativeHere(staticFiles.get(target))
} }
function getStaticName(target) { function getStaticName(target) {
return getRelativeHere(static_files.get(target)).replace(/\?.*$/, "") return getRelativeHere(staticFiles.get(target)).replace(/\?.*$/, "")
} }
function getLink(target) { function getLink(target) {
return getRelativeHere(links.get(target)) return getRelativeHere(links.get(target))
} }
const renderedHTML = pug.compileFile(pj(".", sourcePath), {pretty: true})({getStatic, getStaticName, getLink, ...pugLocals}) const renderedHTML = pug.compileFile(pj(".", sourcePath), {pretty: true})({getStatic, getStaticName, getLink, ...pugLocals})
let renderedWithoutPHP = renderedHTML.replace(/<\?(?:php|=).*?\?>/gsm, "") let renderedWithoutPHP = renderedHTML.replace(/<\?(?:php|=).*?\?>/gsm, "")
await validate(sourcePath, renderedWithoutPHP, "html") validate(sourcePath, renderedWithoutPHP, "html")
await fs.promises.writeFile(pj(buildDir, targetPath), renderedHTML) await fs.promises.writeFile(pj(buildDir, targetPath), renderedHTML)
} }
@ -224,7 +245,7 @@ async function addBabel(sourcePath, targetPath) {
const filenameWithQuery = `${targetPath}?static=${hash(compiled.code)}`; const filenameWithQuery = `${targetPath}?static=${hash(compiled.code)}`;
static_files.set(sourcePath, filenameWithQuery) staticFiles.set(sourcePath, filenameWithQuery)
await Promise.all([ await Promise.all([
fs.promises.writeFile(pj(buildDir, targetPath), originalCode), fs.promises.writeFile(pj(buildDir, targetPath), originalCode),

351
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -11,18 +11,17 @@
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {},
"browserify": "^17.0.0",
"tippy.js": "^6.2.7"
},
"devDependencies": { "devDependencies": {
"@babel/core": "^7.11.1", "@babel/core": "^7.11.1",
"@babel/preset-env": "^7.11.0", "@babel/preset-env": "^7.11.0",
"browserify": "^17.0.0",
"chalk": "^4.1.0", "chalk": "^4.1.0",
"http-server": "^0.12.3", "http-server": "^0.12.3",
"jshint": "^2.12.0", "jshint": "^2.12.0",
"node-fetch": "^2.6.0", "node-fetch": "^2.6.0",
"pug": "^3.0.0", "pug": "^3.0.0",
"sass": "^1.26.10" "sass": "^1.26.10",
"tippy.js": "^6.2.7"
} }
} }

80
spec.js
View file

@ -19,86 +19,6 @@ module.exports = [
source: "/js/main.js", source: "/js/main.js",
target: "/static/bundle.js" target: "/static/bundle.js"
}, },
// {
// type: "js",
// source: "/js/main.js",
// target: "/static/main.js",
// },
// {
// type: "js",
// source: "/js/basic.js",
// target: "/static/basic.js",
// },
// {
// type: "js",
// source: "/js/groups.js",
// target: "/static/groups.js",
// },
// {
// type: "js",
// source: "/js/chat-input.js",
// target: "/static/chat-input.js",
// },
// {
// type: "js",
// source: "/js/room-picker.js",
// target: "/static/room-picker.js",
// },
// {
// type: "js",
// source: "/js/store/store.js",
// target: "/static/store/store.js",
// },
// {
// type: "js",
// source: "/js/store/subscribable.js",
// target: "/static/store/subscribable.js",
// },
// {
// type: "js",
// source: "/js/store/subscribe_value.js",
// target: "/static/store/subscribe_value.js",
// },
// {
// type: "js",
// source: "/js/store/subscribe_map_list.js",
// target: "/static/store/subscribe_map_list.js",
// },
// {
// type: "js",
// source: "/js/store/subscribe_set.js",
// target: "/static/store/subscribe_set.js",
// },
// {
// type: "js",
// source: "/js/sync/sync.js",
// target: "/static/sync/sync.js",
// },
// {
// type: "js",
// source: "/js/lsm.js",
// target: "/static/lsm.js",
// },
// {
// type: "js",
// source: "/js/timeline.js",
// target: "/static/timeline.js",
// },
// {
// type: "js",
// source: "/js/anchor.js",
// target: "/static/anchor.js",
// },
// {
// type: "js",
// source: "/js/chat.js",
// target: "/static/chat.js",
// },
// {
// type: "js",
// source: "/js/functions.js",
// target: "/static/functions.js",
// },
{ {
type: "file", type: "file",
source: "/assets/fonts/whitney-500.woff", source: "/assets/fonts/whitney-500.woff",

View file

@ -27,7 +27,7 @@ class Chat extends ElemJS {
// connect to the new room's timeline updater // connect to the new room's timeline updater
if (store.activeRoom.exists()) { if (store.activeRoom.exists()) {
const timeline = store.activeRoom.value().timeline const timeline = store.activeRoom.value().timeline
const subscription = () => { const beforeChangeSubscription = () => {
// scroll anchor does not work if the timeline is scrolled to the top. // scroll anchor does not work if the timeline is scrolled to the top.
// at the start, when there are not enough messages for a full screen, this is the case. // at the start, when there are not enough messages for a full screen, this is the case.
// once there are enough messages that scrolling is necessary, we initiate a scroll down to activate the scroll anchor. // once there are enough messages that scrolling is necessary, we initiate a scroll down to activate the scroll anchor.
@ -40,12 +40,29 @@ class Chat extends ElemJS {
} }
}, 0) }, 0)
} }
const name = "beforeChange" this.addSubscription("beforeChange", timeline, beforeChangeSubscription)
this.removableSubscriptions.push({name, target: timeline, subscription})
timeline.subscribe(name, subscription) // Make sure after loading scrollback we don't move the scroll position
const beforeScrollbackLoadSubscription = () => {
const lastScrollHeight = chatMessages.scrollHeight;
const afterScrollbackLoadSub = () => {
const scrollDiff = chatMessages.scrollHeight - lastScrollHeight;
chatMessages.scrollTop += scrollDiff;
timeline.unsubscribe("afterScrollbackLoad", afterScrollbackLoadSub)
}
timeline.subscribe("afterScrollbackLoad", afterScrollbackLoadSub)
}
this.addSubscription("beforeScrollbackLoad", timeline, beforeScrollbackLoadSubscription)
} }
this.render() this.render()
} }
addSubscription(name, target, subscription) {
this.removableSubscriptions.push({name, target, subscription})
target.subscribe(name, subscription)
}
render() { render() {
this.clearChildren() this.clearChildren()

View file

@ -1,7 +1,8 @@
const lsm = require("./lsm.js") const lsm = require("./lsm.js")
function resolveMxc(url, size, method) { function resolveMxc(url, size, method) {
const [server, id] = url.match(/^mxc:\/\/([^/]+)\/(.*)/).slice(1) let [server, id] = url.match(/^mxc:\/\/([^/]+)\/(.*)/).slice(1)
id = id.replace(/#.*$/, "")
if (size && method) { if (size && method) {
return `${lsm.get("domain")}/_matrix/media/r0/thumbnail/${server}/${id}?width=${size}&height=${size}&method=${method}` return `${lsm.get("domain")}/_matrix/media/r0/thumbnail/${server}/${id}?width=${size}&height=${size}&method=${method}`
} else { } else {

120
src/js/sender.js Normal file
View file

@ -0,0 +1,120 @@
const {ElemJS, ejs} = require("./basic.js")
const {store} = require("./store/store.js")
const {resolveMxc} = require("./functions.js")
function nameToColor(str) {
// code from element's react sdk
const colors = ["#55a7f0", "#da55ff", "#1bc47c", "#ea657e", "#fd8637", "#22cec6", "#8c8de3", "#71bf22"]
let hash = 0
let i
let chr
if (str.length === 0) {
return hash
}
for (i = 0; i < str.length; i++) {
chr = str.charCodeAt(i)
hash = ((hash << 5) - hash) + chr
hash |= 0
}
hash = Math.abs(hash) % 8
return colors[hash]
}
class Avatar extends ElemJS {
constructor() {
super("div")
this.class("c-message-group__avatar")
this.mxc = undefined
this.image = null
this.update(null)
}
update(mxc) {
if (mxc === this.mxc) return
this.mxc = mxc
this.hasImage = !!mxc
if (this.hasImage) {
const size = 96
const url = resolveMxc(mxc, size, "crop")
this.image = ejs("img").class("c-message-group__icon").attribute("src", url).attribute("width", size).attribute("height", size)
this.image.on("error", this.onError.bind(this))
}
this.render()
}
onError() {
this.hasImage = false
this.render()
}
render() {
this.clearChildren()
if (this.hasImage) {
this.child(this.image)
} else {
this.child(
ejs("div").class("c-message-group__icon", "c-message-group__icon--no-icon")
)
}
}
}
/** Must update at least once to render. */
class Name extends ElemJS {
constructor() {
super("div")
this.class("c-message-group__name")
/**
* Keeps track of whether we have the proper display name or not.
* If we do, then we shoudn't override it with the mxid if the name becomes unavailable.
*/
this.hasName = false
this.name = ""
this.mxid = ""
}
update(event) {
this.mxid = event.state_key
if (event.content.displayname) {
this.hasName = true
this.name = event.content.displayname
} else if (!this.hasName) {
this.name = this.mxid
}
this.render()
}
render() {
// set text
this.text(this.name)
// set color
this.style("color", nameToColor(this.mxid))
}
}
class Sender {
constructor(roomID, mxid) {
this.sender = store.rooms.get(roomID).value().members.get(mxid)
this.name = new Name()
this.avatar = new Avatar()
this.sender.subscribe("changeSelf", this.update.bind(this))
this.update()
}
update() {
if (this.sender.exists()) {
// name
this.name.update(this.sender.value())
// avatar
this.avatar.update(this.sender.value().content.avatar_url)
}
}
}
module.exports = {
Sender
}

View file

@ -2,8 +2,8 @@ const {ElemJS, ejs} = require("./basic.js")
const {Subscribable} = require("./store/subscribable.js") const {Subscribable} = require("./store/subscribable.js")
const {store} = require("./store/store.js") const {store} = require("./store/store.js")
const {Anchor} = require("./anchor.js") const {Anchor} = require("./anchor.js")
const {Sender} = require("./sender.js")
const lsm = require("./lsm.js") const lsm = require("./lsm.js")
const {resolveMxc} = require("./functions.js")
let debug = false let debug = false
@ -33,9 +33,9 @@ function eventSearch(list, event, min = 0, max = NO_MAX) {
} }
} }
// recurse (below) // recurse (below)
if (list[mid].data.origin_server_ts > event.data.origin_server_ts) return eventSearch(list, event, min, mid-1) if (list[mid].data.origin_server_ts > event.data.origin_server_ts) return eventSearch(list, event, min, mid - 1)
// recurse (above) // recurse (above)
else return eventSearch(list, event, mid+1, max) else return eventSearch(list, event, mid + 1, max)
} }
class Event extends ElemJS { class Event extends ElemJS {
@ -100,41 +100,6 @@ class Event extends ElemJS {
} }
} }
class Sender {
constructor(roomID, mxid) {
this.sender = store.rooms.get(roomID).value().members.get(mxid)
this.sender.subscribe("changeSelf", this.update.bind(this))
this.name = new ElemJS("div").class("c-message-group__name")
this.avatar = new ElemJS("div").class("c-message-group__avatar")
this.displayingGoodData = false
this.update()
}
update() {
if (this.sender.exists()) {
// name
if (this.sender.value().content.displayname) {
this.name.text(this.sender.value().content.displayname)
this.displayingGoodData = true
} else if (!this.displayingGoodData) {
this.name.text(this.sender.value().state_key)
}
// avatar
this.avatar.clearChildren()
if (this.sender.value().content.avatar_url) {
this.avatar.child(
ejs("img").class("c-message-group__icon").attribute("src", resolveMxc(this.sender.value().content.avatar_url, 96, "crop"))
)
} else {
this.avatar.child(
ejs("div").class("c-message-group__icon", "c-message-group__icon--no-icon")
)
}
}
}
}
class EventGroup extends ElemJS { class EventGroup extends ElemJS {
constructor(reactive, list) { constructor(reactive, list) {
super("div") super("div")
@ -176,16 +141,43 @@ class EventGroup extends ElemJS {
} }
} }
/** Displays a spinner and creates an event to notify timeline to load more messages */
class LoadMore extends ElemJS {
constructor(id) {
super("div")
this.class("c-message-notice")
this.id = id
this.child(
ejs("div").class("c-message-notice__inner").child(
ejs("span").class("loading-icon"),
ejs("span").text("Loading more...")
)
)
const intersection_observer = new IntersectionObserver(e => this.intersectionHandler(e))
intersection_observer.observe(this.element)
}
intersectionHandler(e) {
if (e.some(e => e.isIntersecting)) {
store.rooms.get(this.id).value().timeline.loadScrollback()
}
}
}
class ReactiveTimeline extends ElemJS { class ReactiveTimeline extends ElemJS {
constructor(id, list) { constructor(id, list) {
super("div") super("div")
this.class("c-event-groups") this.class("c-event-groups")
this.id = id this.id = id
this.list = list this.list = list
this.loadMore = new LoadMore(this.id)
this.render() this.render()
} }
addEvent(event) { addEvent(event) {
this.loadMore.remove()
// if (debug) console.log("running search", this.list, event) // if (debug) console.log("running search", this.list, event)
// if (debug) debugger; // if (debug) debugger;
const search = eventSearch(this.list, event) const search = eventSearch(this.list, event)
@ -193,7 +185,7 @@ class ReactiveTimeline extends ElemJS {
if (!search.success) { if (!search.success) {
if (search.i >= 1) { if (search.i >= 1) {
// add at end // add at end
this.tryAddGroups(event, [search.i-1, search.i]) this.tryAddGroups(event, [search.i - 1, search.i])
} else { } else {
// add at start // add at start
this.tryAddGroups(event, [0, -1]) this.tryAddGroups(event, [0, -1])
@ -201,6 +193,8 @@ class ReactiveTimeline extends ElemJS {
} else { } else {
this.tryAddGroups(event, [search.i]) this.tryAddGroups(event, [search.i])
} }
this.loadMore = new LoadMore(this.id)
this.childAt(0, this.loadMore)
} }
tryAddGroups(event, indices) { tryAddGroups(event, indices) {
@ -233,6 +227,7 @@ class ReactiveTimeline extends ElemJS {
render() { render() {
this.clearChildren() this.clearChildren()
this.child(this.loadMore)
this.list.forEach(group => this.child(group)) this.list.forEach(group => this.child(group))
this.anchor = new Anchor() this.anchor = new Anchor()
this.child(this.anchor) this.child(this.anchor)
@ -244,11 +239,15 @@ class Timeline extends Subscribable {
super() super()
Object.assign(this.events, { Object.assign(this.events, {
beforeChange: [], beforeChange: [],
afterChange: [] afterChange: [],
beforeScrollbackLoad: [],
afterScrollbackLoad: [],
}) })
Object.assign(this.eventDeps, { Object.assign(this.eventDeps, {
beforeChange: [], beforeChange: [],
afterChange: [] afterChange: [],
beforeScrollbackLoad: [],
afterScrollbackLoad: [],
}) })
this.room = room this.room = room
this.id = this.room.id this.id = this.room.id
@ -267,7 +266,11 @@ class Timeline extends Subscribable {
if (eventData.type === "m.room.member") { if (eventData.type === "m.room.member") {
// update members // update members
if (eventData.membership !== "leave") { if (eventData.membership !== "leave") {
this.room.members.get(eventData.state_key).set(eventData) const member = this.room.members.get(eventData.state_key)
// only use the latest state
if (!member.exists() || eventData.origin_server_ts > member.data.origin_server_ts) {
member.set(eventData)
}
} }
} }
} }
@ -349,16 +352,27 @@ class Timeline extends Subscribable {
url.searchParams.set("access_token", lsm.get("access_token")) url.searchParams.set("access_token", lsm.get("access_token"))
url.searchParams.set("from", this.from) url.searchParams.set("from", this.from)
url.searchParams.set("dir", "b") url.searchParams.set("dir", "b")
url.searchParams.set("limit", 10) url.searchParams.set("limit", "20")
const filter = { const filter = {
lazy_load_members: true lazy_load_members: true
} }
url.searchParams.set("filter", JSON.stringify(filter)) url.searchParams.set("filter", JSON.stringify(filter))
const root = await fetch(url.toString()).then(res => res.json()) const root = await fetch(url.toString()).then(res => res.json())
this.broadcast("beforeScrollbackLoad")
this.from = root.end this.from = root.end
console.log(this.updateEvents, root.chunk) // console.log(this.updateEvents, root.chunk)
if (root.state) this.updateStateEvents(root.state) if (root.state) this.updateStateEvents(root.state)
if (root.chunk.length) {
// there are events to display
this.updateEvents(root.chunk) this.updateEvents(root.chunk)
} else {
// we reached the top of the scrollback
this.reactiveTimeline.loadMore.remove()
}
this.broadcast("afterScrollbackLoad")
} }
send(body) { send(body) {
@ -385,32 +399,8 @@ class Timeline extends Subscribable {
headers: { headers: {
"Content-Type": "application/json" "Content-Type": "application/json"
} }
})/*.then(() => { })
const subscription = () => {
this.removeEvent(id)
this.unsubscribe("afterChange", subscription)
} }
this.subscribe("afterChange", subscription)
})*/
}
/*
getGroupedEvents() {
let currentSender = Symbol("N/A")
let groups = []
let currentGroup = []
for (const event of this.list) {
if (event.sender === currentSender) {
currentGroup.push(event)
} else {
if (currentGroup.length) groups.push(currentGroup)
currentGroup = [event]
currentSender = event.sender
}
}
if (currentGroup.length) groups.push(currentGroup)
return groups
}
*/
} }
module.exports = {Timeline} module.exports = {Timeline}

View file

@ -23,7 +23,7 @@
border-radius: 50% border-radius: 50%
&--no-icon &--no-icon
background-color: #48d background-color: #bbb
&__intro &__intro
display: flex display: flex
@ -46,6 +46,7 @@
.c-message .c-message
margin-top: 4px margin-top: 4px
overflow-wrap: anywhere
opacity: 1 opacity: 1
transition: opacity 0.2s ease-out transition: opacity 0.2s ease-out

13
src/sass/loading.sass Normal file
View file

@ -0,0 +1,13 @@
@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

View file

@ -1,7 +1,9 @@
@use "./base" @use "./base"
@use "./loading"
@use "./colors" as c @use "./colors" as c
@use "./tippy" @use "./tippy"
.main .main
justify-content: center justify-content: center
align-items: center align-items: center

View file

@ -5,3 +5,4 @@
@use "./components/chat" @use "./components/chat"
@use "./components/chat-input" @use "./components/chat-input"
@use "./components/anchor" @use "./components/anchor"
@use "./loading"