Compare commits
107 commits
Author | SHA1 | Date | |
---|---|---|---|
734506a300 | |||
f802031848 | |||
879c09f70b | |||
9dce348a4c | |||
ea6ccc08ee | |||
2e91ff8ff2 | |||
a004e84adc | |||
b4dfefbac9 | |||
e6fc1de276 | |||
70cae25aa7 | |||
6e209bafd6 | |||
bc861125d8 | |||
6297350418 | |||
69a9e2ed2f | |||
b7905bc3be | |||
babd098d18 | |||
229e6903fd | |||
e90a2c7da8 | |||
03c7501bf1 | |||
0960ca7e97 | |||
c0c7278279 | |||
9f6c955b63 | |||
f4b13dbde4 | |||
c87b6dcaa7 | |||
eb573fc17c | |||
f188d66645 | |||
4acd806e66 | |||
327290e971 | |||
d6be694d3b | |||
f6b95b2ebd | |||
951a46d8ec | |||
6583c192ce | |||
34af1be7d1 | |||
1fa7da9ebb | |||
b74f0cc0dd | |||
1aebc2c100 | |||
017f30be65 | |||
a7165fe633 | |||
8ba9d73b33 | |||
9cf0952d3a | |||
714147b980 | |||
ebf6e7ea78 | |||
1bf1712684 | |||
0738ce4cb1 | |||
20e94f05e7 | |||
4d59b1a9ac | |||
5a41a2c943 | |||
20bacce068 | |||
f80bf36991 | |||
217a815750 | |||
bd9623578f | |||
c144d75c99 | |||
66ecf44048 | |||
ff196a64bb | |||
a4c7f29ec9 | |||
5bfe98bdf4 | |||
e08b895694 | |||
d983385e16 | |||
f46f9abe6e | |||
1a8427925c | |||
|
098ea88f5d | ||
72b42e7b26 | |||
f4b368ea3e | |||
6da9f41519 | |||
df47c8a88a | |||
5ab182e615 | |||
08a0990bc8 | |||
0348fed18d | |||
c9dffc9d4a | |||
6227f6fa84 | |||
51905ab3f2 | |||
d90be7a0d4 | |||
79aa423ebb | |||
91ac7a6b3e | |||
aa12cd68e6 | |||
4382928a93 | |||
2f5955b043 | |||
36f204624f | |||
6499cd4ff6 | |||
e18c8c77ae | |||
16de7edd19 | |||
0113024be6 | |||
1b97351ca0 | |||
dce4fa6303 | |||
2ff43ea801 | |||
9e71336c5b | |||
61cc4a19f3 | |||
0c3c06bc0a | |||
6f67ddbce5 | |||
f1b75f5e10 | |||
c8351e8f0c | |||
cf2c691585 | |||
ff427d0354 | |||
a56c42311f | |||
64c3e18788 | |||
a7ded5fae3 | |||
4869a31ec2 | |||
253ccbadc2 | |||
b83cede3ec | |||
b9df147db5 | |||
184c876fb9 | |||
988dd1050b | |||
735ca360c8 | |||
265d774b4f | |||
3fc8104bdd | |||
c21ab3b90f | |||
3e28d4b6e1 |
33
.drone.yml
|
@ -6,16 +6,40 @@ steps:
|
||||||
- name: build
|
- name: build
|
||||||
image: node:current-alpine3.12
|
image: node:current-alpine3.12
|
||||||
commands:
|
commands:
|
||||||
|
- apk update
|
||||||
|
- apk add git
|
||||||
- npm install -D
|
- npm install -D
|
||||||
- npm run rebuild
|
- npm run rebuild
|
||||||
|
|
||||||
- name: package
|
- name: package
|
||||||
image: fuww/alpine-zip
|
image: fuww/alpine-zip
|
||||||
|
when:
|
||||||
|
event:
|
||||||
|
- push
|
||||||
commands:
|
commands:
|
||||||
- SHORTREV=`echo $DRONE_COMMIT | cut -b 1-8`
|
- SHORTREV=`echo $DRONE_COMMIT | cut -b 1-8`
|
||||||
- echo FILENAME=`date +%Y%m%d%H%m`-$SHORTREV.zip >> environment
|
- echo NAME=`date +%Y%m%d%H%m`-$SHORTREV >> environment
|
||||||
- source environment
|
- source environment
|
||||||
- zip -r $FILENAME build
|
- zip -r $NAME.zip build
|
||||||
|
|
||||||
|
- name: dev.carbon.chat
|
||||||
|
image: drillster/drone-rsync
|
||||||
|
when:
|
||||||
|
event:
|
||||||
|
- push
|
||||||
|
settings:
|
||||||
|
hosts:
|
||||||
|
from_secret: SSH_HOST
|
||||||
|
port:
|
||||||
|
from_secret: SSH_PORT
|
||||||
|
user:
|
||||||
|
from_secret: SSH_USERNAME
|
||||||
|
key:
|
||||||
|
from_secret: SSH_KEY
|
||||||
|
source: ./build/*
|
||||||
|
target: ${DRONE_COMMIT_SHA:0:8}
|
||||||
|
recursive: true
|
||||||
|
|
||||||
|
|
||||||
- name: b2
|
- name: b2
|
||||||
image: tianon/backblaze-b2:2
|
image: tianon/backblaze-b2:2
|
||||||
|
@ -28,8 +52,9 @@ steps:
|
||||||
from_secret: b2_account_id
|
from_secret: b2_account_id
|
||||||
KEY:
|
KEY:
|
||||||
from_secret: b2_application_key
|
from_secret: b2_application_key
|
||||||
|
COMMIT: ${DRONE_COMMIT_SHA:0:8}
|
||||||
commands:
|
commands:
|
||||||
- source environment
|
- source environment
|
||||||
- b2 authorize-account $ACCOUNT $KEY
|
- b2 authorize-account $ACCOUNT $KEY
|
||||||
- b2 upload-file $BUCKET $FILENAME $FILENAME
|
- b2 upload-file $BUCKET $NAME.zip $NAME.zip
|
||||||
- echo Build artifacts avaliable at `b2 make-friendly-url $BUCKET $FILENAME`
|
- echo Build artifacts avaliable at `b2 make-friendly-url $BUCKET $NAME.zip` and at https://dev.carbon.chat/$COMMIT
|
||||||
|
|
301
.gitignore
vendored
|
@ -1,10 +1,297 @@
|
||||||
# Generated files
|
# File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig
|
||||||
node_modules
|
|
||||||
build
|
|
||||||
|
|
||||||
# Editor artifacts
|
# Created by https://www.toptal.com/developers/gitignore/api/node,vscode,webstorm,webstorm+all
|
||||||
.vscode
|
# Edit at https://www.toptal.com/developers/gitignore?templates=node,vscode,webstorm,webstorm+all
|
||||||
|
|
||||||
|
### Node ###
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# TypeScript v1 declaration files
|
||||||
|
typings/
|
||||||
|
|
||||||
|
# TypeScript cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Microbundle cache
|
||||||
|
.rpt2_cache/
|
||||||
|
.rts2_cache_cjs/
|
||||||
|
.rts2_cache_es/
|
||||||
|
.rts2_cache_umd/
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variables file
|
||||||
|
.env
|
||||||
|
.env.test
|
||||||
|
.env*.local
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
.parcel-cache
|
||||||
|
|
||||||
|
# Next.js build output
|
||||||
|
.next
|
||||||
|
|
||||||
|
# Nuxt.js build / generate output
|
||||||
|
.nuxt
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Gatsby files
|
||||||
|
.cache/
|
||||||
|
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||||
|
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||||
|
# public
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless/
|
||||||
|
|
||||||
|
# FuseBox cache
|
||||||
|
.fusebox/
|
||||||
|
|
||||||
|
# DynamoDB Local files
|
||||||
|
.dynamodb/
|
||||||
|
|
||||||
|
# TernJS port file
|
||||||
|
.tern-port
|
||||||
|
|
||||||
|
# Stores VSCode versions used for testing VSCode extensions
|
||||||
|
.vscode-test
|
||||||
|
|
||||||
|
### vscode ###
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/settings.json
|
||||||
|
!.vscode/tasks.json
|
||||||
|
!.vscode/launch.json
|
||||||
|
!.vscode/extensions.json
|
||||||
|
*.code-workspace
|
||||||
|
|
||||||
|
### WebStorm ###
|
||||||
|
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||||
|
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||||
|
|
||||||
|
# User-specific stuff
|
||||||
|
.idea/**/workspace.xml
|
||||||
|
.idea/**/tasks.xml
|
||||||
|
.idea/**/usage.statistics.xml
|
||||||
|
.idea/**/dictionaries
|
||||||
|
.idea/**/shelf
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
.idea/**/contentModel.xml
|
||||||
|
|
||||||
|
# Sensitive or high-churn files
|
||||||
|
.idea/**/dataSources/
|
||||||
|
.idea/**/dataSources.ids
|
||||||
|
.idea/**/dataSources.local.xml
|
||||||
|
.idea/**/sqlDataSources.xml
|
||||||
|
.idea/**/dynamic.xml
|
||||||
|
.idea/**/uiDesigner.xml
|
||||||
|
.idea/**/dbnavigator.xml
|
||||||
|
|
||||||
|
# Gradle
|
||||||
|
.idea/**/gradle.xml
|
||||||
|
.idea/**/libraries
|
||||||
|
|
||||||
|
# Gradle and Maven with auto-import
|
||||||
|
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||||
|
# since they will be recreated, and may cause churn. Uncomment if using
|
||||||
|
# auto-import.
|
||||||
|
# .idea/artifacts
|
||||||
|
# .idea/compiler.xml
|
||||||
|
# .idea/jarRepositories.xml
|
||||||
|
# .idea/modules.xml
|
||||||
|
# .idea/*.iml
|
||||||
|
# .idea/modules
|
||||||
|
# *.iml
|
||||||
|
# *.ipr
|
||||||
|
|
||||||
|
# CMake
|
||||||
|
cmake-build-*/
|
||||||
|
|
||||||
|
# Mongo Explorer plugin
|
||||||
|
.idea/**/mongoSettings.xml
|
||||||
|
|
||||||
|
# File-based project format
|
||||||
|
*.iws
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
out/
|
||||||
|
|
||||||
|
# mpeltonen/sbt-idea plugin
|
||||||
|
.idea_modules/
|
||||||
|
|
||||||
|
# JIRA plugin
|
||||||
|
atlassian-ide-plugin.xml
|
||||||
|
|
||||||
|
# Cursive Clojure plugin
|
||||||
|
.idea/replstate.xml
|
||||||
|
|
||||||
|
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||||
|
com_crashlytics_export_strings.xml
|
||||||
|
crashlytics.properties
|
||||||
|
crashlytics-build.properties
|
||||||
|
fabric.properties
|
||||||
|
|
||||||
|
# Editor-based Rest Client
|
||||||
|
.idea/httpRequests
|
||||||
|
|
||||||
|
# Android studio 3.1+ serialized cache file
|
||||||
|
.idea/caches/build_file_checksums.ser
|
||||||
|
|
||||||
|
### WebStorm Patch ###
|
||||||
|
# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
|
||||||
|
|
||||||
|
# *.iml
|
||||||
|
# modules.xml
|
||||||
|
# .idea/misc.xml
|
||||||
|
# *.ipr
|
||||||
|
|
||||||
|
# Sonarlint plugin
|
||||||
|
# https://plugins.jetbrains.com/plugin/7973-sonarlint
|
||||||
|
.idea/**/sonarlint/
|
||||||
|
|
||||||
|
# SonarQube Plugin
|
||||||
|
# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
|
||||||
|
.idea/**/sonarIssues.xml
|
||||||
|
|
||||||
|
# Markdown Navigator plugin
|
||||||
|
# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
|
||||||
|
.idea/**/markdown-navigator.xml
|
||||||
|
.idea/**/markdown-navigator-enh.xml
|
||||||
|
.idea/**/markdown-navigator/
|
||||||
|
|
||||||
|
# Cache file creation bug
|
||||||
|
# See https://youtrack.jetbrains.com/issue/JBR-2257
|
||||||
|
.idea/$CACHE_FILE$
|
||||||
|
|
||||||
|
# CodeStream plugin
|
||||||
|
# https://plugins.jetbrains.com/plugin/12206-codestream
|
||||||
|
.idea/codestream.xml
|
||||||
|
|
||||||
|
### WebStorm+all ###
|
||||||
|
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||||
|
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||||
|
|
||||||
|
# User-specific stuff
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
|
||||||
|
# Sensitive or high-churn files
|
||||||
|
|
||||||
|
# Gradle
|
||||||
|
|
||||||
|
# Gradle and Maven with auto-import
|
||||||
|
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||||
|
# since they will be recreated, and may cause churn. Uncomment if using
|
||||||
|
# auto-import.
|
||||||
|
# .idea/artifacts
|
||||||
|
# .idea/compiler.xml
|
||||||
|
# .idea/jarRepositories.xml
|
||||||
|
# .idea/modules.xml
|
||||||
|
# .idea/*.iml
|
||||||
|
# .idea/modules
|
||||||
|
# *.iml
|
||||||
|
# *.ipr
|
||||||
|
|
||||||
|
# CMake
|
||||||
|
|
||||||
|
# Mongo Explorer plugin
|
||||||
|
|
||||||
|
# File-based project format
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
|
||||||
|
# mpeltonen/sbt-idea plugin
|
||||||
|
|
||||||
|
# JIRA plugin
|
||||||
|
|
||||||
|
# Cursive Clojure plugin
|
||||||
|
|
||||||
|
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||||
|
|
||||||
|
# Editor-based Rest Client
|
||||||
|
|
||||||
|
# Android studio 3.1+ serialized cache file
|
||||||
|
|
||||||
|
### WebStorm+all Patch ###
|
||||||
|
# Ignores the whole .idea folder and all .iml files
|
||||||
|
# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360
|
||||||
|
|
||||||
|
.idea/
|
||||||
|
|
||||||
|
# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023
|
||||||
|
|
||||||
|
*.iml
|
||||||
|
modules.xml
|
||||||
|
.idea/misc.xml
|
||||||
|
*.ipr
|
||||||
|
|
||||||
|
# Sonarlint plugin
|
||||||
|
.idea/sonarlint
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
/build/
|
||||||
|
|
56
README.md
|
@ -2,12 +2,35 @@
|
||||||
|
|
||||||
Carbon is the Matrix client for Discord and Guilded refugees.
|
Carbon is the Matrix client for Discord and Guilded refugees.
|
||||||
|
|
||||||
|
Visit the hosted instance on
|
||||||
|
[https://carbon.chat](https://carbon.chat).
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Carbon is **abandoned** by its author, but it is still solid code to build on for anyone with the time and inclination to pick it up.
|
||||||
|
|
||||||
|
## Report bugs and suggest features
|
||||||
|
|
||||||
|
Please briefly check this README and the issues page first to make
|
||||||
|
sure that the issue/feature is not already known!
|
||||||
|
|
||||||
|
- If you already have an account on Gitdab, use the issues page.
|
||||||
|
- If you don't have an account, and don't wish to create one, you can
|
||||||
|
send an email to the [mailing list].
|
||||||
|
|
||||||
|
If something in the interface isn't working as you think it should,
|
||||||
|
please provide a screenshot of any messages from the browser devtools
|
||||||
|
console. If using the mailing list, attachments aren't supported, so
|
||||||
|
you'll have to upload to some image host and post the link.
|
||||||
|
|
||||||
|
[mailing list]: https://lists.sr.ht/~cadence/carbon-discuss
|
||||||
|
|
||||||
## The dream
|
## The dream
|
||||||
|
|
||||||
Carbon's planned features, compared to Discord and Guilded:
|
Carbon's planned features, compared to Discord and Guilded:
|
||||||
|
|
||||||
- End to end encryption
|
- End to end encryption
|
||||||
- Free of charge per-account custom emojis and custom emoji packs
|
- Free of charge, per-account, custom emojis and custom emoji packs
|
||||||
- No limit to number of groups you can join at a time
|
- No limit to number of groups you can join at a time
|
||||||
- Uses the open Matrix and Mumble systems
|
- Uses the open Matrix and Mumble systems
|
||||||
- Much better IRC layout
|
- Much better IRC layout
|
||||||
|
@ -35,25 +58,39 @@ Carbon is currently _technically_ usable as a chat app, but is very
|
||||||
early in development. These important features still need to be
|
early in development. These important features still need to be
|
||||||
implemented:
|
implemented:
|
||||||
|
|
||||||
- Login GUI
|
|
||||||
- Unreads
|
|
||||||
- Chat history
|
|
||||||
- Formatting
|
|
||||||
- Emojis
|
- Emojis
|
||||||
- Reactions
|
- Reactions
|
||||||
|
- Encryption
|
||||||
- Groups v2
|
- Groups v2
|
||||||
- Group management
|
- Group management
|
||||||
- Pinned channels
|
- Pinned channels
|
||||||
- Mumble integration
|
- Mumble integration
|
||||||
|
|
||||||
|
For more information, see [issue
|
||||||
|
#10.](https://gitdab.com/cadence/Carbon/issues/10)
|
||||||
|
|
||||||
## The code
|
## The code
|
||||||
|
|
||||||
### Building
|
### Downloading a CI build
|
||||||
|
|
||||||
|
Visit [drone CI](https://drone.badat.dev/cadence/Carbon/branches),
|
||||||
|
select the branch you want to use, select `b2` on the left, scroll
|
||||||
|
down, and open the URL on the last line to download the build.
|
||||||
|
|
||||||
|
### Building from source yourself
|
||||||
|
|
||||||
|
Dependencies:
|
||||||
|
|
||||||
|
- git
|
||||||
|
- node
|
||||||
|
- npm (bundled with node)
|
||||||
|
|
||||||
|
Build:
|
||||||
|
|
||||||
npm install -D
|
npm install -D
|
||||||
npm run rebuild
|
npm run rebuild
|
||||||
|
|
||||||
### Hosting
|
### Hosting a build
|
||||||
|
|
||||||
Send the files from the `build` folder to a static file server. Apply
|
Send the files from the `build` folder to a static file server. Apply
|
||||||
a long cache-control header to everything served under `/static`, and
|
a long cache-control header to everything served under `/static`, and
|
||||||
|
@ -64,3 +101,8 @@ no cache-control header to everything else.
|
||||||
npm run watch
|
npm run watch
|
||||||
|
|
||||||
Files will be rebuilt as you save them.
|
Files will be rebuilt as you save them.
|
||||||
|
|
||||||
|
Use `python3 -m http.server -d build` to serve the build on
|
||||||
|
[http://localhost:8000](http://localhost:8000).
|
||||||
|
|
||||||
|
(Avoid `npx http-server`, it caches too much stuff.)
|
||||||
|
|
92
build.js
|
@ -1,6 +1,6 @@
|
||||||
const pug = require("pug")
|
const pug = require("pug")
|
||||||
const sass = require("sass")
|
const sass = require("sass")
|
||||||
const fs = require("fs").promises
|
const fs = require("fs")
|
||||||
const os = require("os")
|
const os = require("os")
|
||||||
const crypto = require("crypto")
|
const crypto = require("crypto")
|
||||||
const path = require("path")
|
const path = require("path")
|
||||||
|
@ -9,6 +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 {Transform} = require("stream")
|
||||||
|
|
||||||
process.chdir(pj(__dirname, "src"))
|
process.chdir(pj(__dirname, "src"))
|
||||||
|
|
||||||
|
@ -16,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 = 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, links}
|
const pugLocals = {static: staticFiles, links}
|
||||||
|
|
||||||
const spec = require("./spec.js")
|
const spec = require("./spec.js")
|
||||||
|
|
||||||
|
@ -94,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
|
||||||
|
@ -125,26 +128,54 @@ function runHint(filename, source) {
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addFile(sourcePath, targetPath) {
|
async function addFile(sourcePath, targetPath) {
|
||||||
const contents = await fs.readFile(pj(".", sourcePath), {encoding: null})
|
const contents = await fs.promises.readFile(pj(".", sourcePath), {encoding: null});
|
||||||
static.set(sourcePath, `${targetPath}?static=${hash(contents)}`)
|
staticFiles.set(sourcePath, `${targetPath}?static=${hash(contents)}`)
|
||||||
fs.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.readFile(pj(".", sourcePath), {encoding: "utf8"})
|
let content = await fs.promises.readFile(pj(".", sourcePath), {encoding: "utf8"})
|
||||||
sources.set(sourcePath, content)
|
sources.set(sourcePath, content)
|
||||||
static.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.get(file)) throw new Error(`Tried to relative import ${file} from ${sourcePath}, but import not found`)
|
|
||||||
return '"' + getRelative(targetPath, static.get(file)) + '"'
|
|
||||||
})
|
|
||||||
runHint(sourcePath, content)
|
runHint(sourcePath, content)
|
||||||
fs.writeFile(pj(buildDir, targetPath), content)
|
await fs.promises.writeFile(pj(buildDir, targetPath), content)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function addBundle(sourcePath, targetPath, module = false) {
|
||||||
|
let opts = {}
|
||||||
|
if (module) opts.standalone = sourcePath
|
||||||
|
const content = await new Promise(resolve => {
|
||||||
|
browserify([], opts)
|
||||||
|
.add(pj(".", sourcePath))
|
||||||
|
.transform(file => {
|
||||||
|
let content = ""
|
||||||
|
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)}`)
|
||||||
|
await writer
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addSass(sourcePath, targetPath) {
|
async function addSass(sourcePath, targetPath) {
|
||||||
|
@ -158,7 +189,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.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 {
|
||||||
|
@ -166,10 +197,10 @@ async function addSass(sourcePath, targetPath) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}).css
|
}).css;
|
||||||
static.set(sourcePath, `${targetPath}?static=${hash(renderedCSS)}`)
|
staticFiles.set(sourcePath, `${targetPath}?static=${hash(renderedCSS)}`)
|
||||||
validate(sourcePath, renderedCSS, "css")
|
validate(sourcePath, renderedCSS, "css")
|
||||||
await fs.writeFile(pj(buildDir, targetPath), renderedCSS)
|
await fs.promises.writeFile(pj(buildDir, targetPath), renderedCSS)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addPug(sourcePath, targetPath) {
|
async function addPug(sourcePath, targetPath) {
|
||||||
|
@ -177,10 +208,10 @@ async function addPug(sourcePath, targetPath) {
|
||||||
return getRelative(targetPath, staticTarget)
|
return getRelative(targetPath, staticTarget)
|
||||||
}
|
}
|
||||||
function getStatic(target) {
|
function getStatic(target) {
|
||||||
return getRelativeHere(static.get(target))
|
return getRelativeHere(staticFiles.get(target))
|
||||||
}
|
}
|
||||||
function getStaticName(target) {
|
function getStaticName(target) {
|
||||||
return getRelativeHere(static.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))
|
||||||
|
@ -188,11 +219,11 @@ async function addPug(sourcePath, targetPath) {
|
||||||
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, "")
|
||||||
validate(sourcePath, renderedWithoutPHP, "html")
|
validate(sourcePath, renderedWithoutPHP, "html")
|
||||||
await fs.writeFile(pj(buildDir, targetPath), renderedHTML)
|
await fs.promises.writeFile(pj(buildDir, targetPath), renderedHTML)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addBabel(sourcePath, targetPath) {
|
async function addBabel(sourcePath, targetPath) {
|
||||||
const originalCode = await fs.readFile(pj(".", sourcePath), "utf8")
|
const originalCode = await fs.promises.readFile(pj(".", sourcePath), "utf8")
|
||||||
|
|
||||||
const compiled = babel.transformSync(originalCode, {
|
const compiled = babel.transformSync(originalCode, {
|
||||||
sourceMaps: false,
|
sourceMaps: false,
|
||||||
|
@ -213,14 +244,14 @@ async function addBabel(sourcePath, targetPath) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const filenameWithQuery = `${targetPath}?static=${hash(compiled.code)}`
|
const filenameWithQuery = `${targetPath}?static=${hash(compiled.code)}`;
|
||||||
|
|
||||||
static.set(sourcePath, filenameWithQuery)
|
staticFiles.set(sourcePath, filenameWithQuery)
|
||||||
|
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fs.writeFile(pj(buildDir, targetPath), originalCode),
|
fs.promises.writeFile(pj(buildDir, targetPath), originalCode),
|
||||||
fs.writeFile(pj(buildDir, minFilename), compiled.code),
|
fs.promises.writeFile(pj(buildDir, minFilename), compiled.code),
|
||||||
fs.writeFile(pj(buildDir, mapFilename), JSON.stringify(compiled.map))
|
fs.promises.writeFile(pj(buildDir, mapFilename), JSON.stringify(compiled.map))
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -241,7 +272,7 @@ async function addBabel(sourcePath, targetPath) {
|
||||||
|
|
||||||
// Stage 3: Create dirs
|
// Stage 3: Create dirs
|
||||||
const dirs = [...new Set(spec.map(item => path.dirname(item.target))).values()]
|
const dirs = [...new Set(spec.map(item => path.dirname(item.target))).values()]
|
||||||
await Promise.all(dirs.map(d => fs.mkdir(pj(buildDir, d), {recursive: true})))
|
await Promise.all(dirs.map(d => fs.promises.mkdir(pj(buildDir, d), {recursive: true})))
|
||||||
|
|
||||||
// Stage 4: Build
|
// Stage 4: Build
|
||||||
for (const item of spec) {
|
for (const item of spec) {
|
||||||
|
@ -255,6 +286,11 @@ async function addBabel(sourcePath, targetPath) {
|
||||||
await addBabel(item.source, item.target)
|
await addBabel(item.source, item.target)
|
||||||
} else if (item.type === "pug") {
|
} else if (item.type === "pug") {
|
||||||
await addPug(item.source, item.target)
|
await addPug(item.source, item.target)
|
||||||
|
} else if (item.type === "bundle") {
|
||||||
|
await addBundle(item.source, item.target)
|
||||||
|
} else if (item.type === "module") {
|
||||||
|
// Creates a standalone bundle that can be imported on runtime
|
||||||
|
await addBundle(item.source, item.target, true)
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Unknown item type: "+item.type)
|
throw new Error("Unknown item type: "+item.type)
|
||||||
}
|
}
|
||||||
|
|
2036
package-lock.json
generated
12
package.json
|
@ -5,21 +5,25 @@
|
||||||
"main": "build.js",
|
"main": "build.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "node build.js",
|
"build": "node build.js",
|
||||||
"watch": "fish -c 'while true; echo -n \"Build started at \"; date; npm run build; inotifywait (find src -type f) build.js -e close_write -qq; end'",
|
"watch": "sh -c 'while true; do echo -n \"Build started at \"; date; npm run build; inotifywait $(find src -type f) build.js spec.js package.json -e close_write -qq; done'",
|
||||||
"rebuild": "rm build -rf && node build.js"
|
"rebuild": "rm build -rf && node build.js"
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "AGPL-3.0-only",
|
"license": "AGPL-3.0-only",
|
||||||
"dependencies": {},
|
"dependencies": {
|
||||||
"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",
|
||||||
|
"discord-markdown": "git+https://git.sr.ht/~cadence/nodejs-discord-markdown#5ad8046d8d62a7fb8047e1a697c3848744d4e64d",
|
||||||
|
"dompurify": "^2.2.0",
|
||||||
|
"highlight.js": "^10.3.2",
|
||||||
"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"
|
||||||
}
|
},
|
||||||
|
"devDependencies": {}
|
||||||
}
|
}
|
||||||
|
|
139
spec.js
|
@ -2,121 +2,96 @@ module.exports = [
|
||||||
{
|
{
|
||||||
type: "file",
|
type: "file",
|
||||||
source: "/assets/fonts/whitney-500.woff",
|
source: "/assets/fonts/whitney-500.woff",
|
||||||
target: "/static/whitney-500.woff"
|
target: "/static/whitney-500.woff",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "file",
|
type: "file",
|
||||||
source: "/assets/fonts/whitney-400.woff",
|
source: "/assets/fonts/whitney-400.woff",
|
||||||
target: "/static/whitney-400.woff"
|
target: "/static/whitney-400.woff",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "js",
|
type: "bundle",
|
||||||
source: "/js/basic.js",
|
source: "/js/login.js",
|
||||||
target: "/static/basic.js"
|
target: "/static/login.js",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "js",
|
type: "bundle",
|
||||||
source: "/js/groups.js",
|
source: "/js/main.js",
|
||||||
target: "/static/groups.js"
|
target: "/static/bundle.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/SubscribeValue.js",
|
|
||||||
target: "/static/store/SubscribeValue.js"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "js",
|
|
||||||
source: "/js/store/SubscribeMapList.js",
|
|
||||||
target: "/static/store/SubscribeMapList.js"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
type: "js",
|
|
||||||
source: "/js/store/SubscribeSet.js",
|
|
||||||
target: "/static/store/SubscribeSet.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",
|
||||||
target: "/static/whitney-500.woff"
|
target: "/static/whitney-500.woff",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "file",
|
type: "file",
|
||||||
source: "/assets/icons/directs.svg",
|
source: "/assets/icons/directs.svg",
|
||||||
target: "/static/directs.svg"
|
target: "/static/directs.svg",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "file",
|
type: "file",
|
||||||
source: "/assets/icons/channels.svg",
|
source: "/assets/icons/channels.svg",
|
||||||
target: "/static/channels.svg"
|
target: "/static/channels.svg",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "file",
|
type: "file",
|
||||||
source: "/assets/icons/join-event.svg",
|
source: "/assets/icons/join-event.svg",
|
||||||
target: "/static/join-event.svg"
|
target: "/static/join-event.svg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
source: "/assets/icons/leave-event.svg",
|
||||||
|
target: "/static/leave-event.svg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
source: "/assets/icons/invite-event.svg",
|
||||||
|
target: "/static/invite-event.svg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
source: "/assets/icons/profile-event.svg",
|
||||||
|
target: "/static/profile-event.svg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
source: "/assets/icons/call-out.svg",
|
||||||
|
target: "/static/call-out.svg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
source: "/assets/icons/call-in.svg",
|
||||||
|
target: "/static/call-in.svg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
source: "/assets/icons/call-accepted.svg",
|
||||||
|
target: "/static/call-accepted.svg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "file",
|
||||||
|
source: "/assets/icons/call-rejected.svg",
|
||||||
|
target: "/static/call-rejected.svg",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "sass",
|
type: "sass",
|
||||||
source: "/sass/main.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",
|
type: "pug",
|
||||||
source: "/home.pug",
|
source: "/home.pug",
|
||||||
target: "/index.html"
|
target: "/index.html",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: "pug",
|
type: "pug",
|
||||||
source: "/login.pug",
|
source: "/login.pug",
|
||||||
target: "/login.html"
|
target: "/login/index.html",
|
||||||
}
|
},
|
||||||
]
|
];
|
||||||
|
|
94
src/assets/icons/call-accepted.svg
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1826"
|
||||||
|
sodipodi:docname="call-accepted.svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
inkscape:export-filename="/home/cloud/Code/Carbon/src/assets/icons/call-out-accepted.svg.png"
|
||||||
|
inkscape:export-xdpi="96"
|
||||||
|
inkscape:export-ydpi="96"
|
||||||
|
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)">
|
||||||
|
<metadata
|
||||||
|
id="metadata1830">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
|
<dc:format>image/svg+xml</dc:format>
|
||||||
|
<dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||||
|
<dc:title>free-icons-solid</dc:title>
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
<sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1440"
|
||||||
|
inkscape:window-height="879"
|
||||||
|
id="namedview1828"
|
||||||
|
showgrid="true"
|
||||||
|
inkscape:snap-global="true"
|
||||||
|
inkscape:snap-bbox="false"
|
||||||
|
inkscape:snap-bbox-edge-midpoints="true"
|
||||||
|
inkscape:bbox-nodes="true"
|
||||||
|
inkscape:bbox-paths="false"
|
||||||
|
inkscape:object-paths="false"
|
||||||
|
inkscape:snap-intersection-paths="true"
|
||||||
|
inkscape:snap-midpoints="false"
|
||||||
|
inkscape:snap-smooth-nodes="false"
|
||||||
|
showguides="true"
|
||||||
|
inkscape:zoom="32"
|
||||||
|
inkscape:cx="7.9954672"
|
||||||
|
inkscape:cy="9.9614234"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg1826">
|
||||||
|
<inkscape:grid
|
||||||
|
type="xygrid"
|
||||||
|
id="grid2773"
|
||||||
|
originx="-158"
|
||||||
|
originy="-1412" />
|
||||||
|
</sodipodi:namedview>
|
||||||
|
<defs
|
||||||
|
id="defs4">
|
||||||
|
<style
|
||||||
|
id="style2">.a{fill:#757575;}.b{fill:#b4b4b4;}.c{fill:#767676;}</style>
|
||||||
|
</defs>
|
||||||
|
<title
|
||||||
|
id="title6">free-icons-solid</title>
|
||||||
|
<path
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
class="a"
|
||||||
|
d="m 15,13 h -3 c -0.460994,-2.45e-4 -1.000244,0.539006 -1,1 l 0.0029,0.66952 C 7.75933,14.711832 4.2952877,11.24779 4.3376001,8.004177 L 5,8 C 5.4609939,8.000244 6.0002445,7.4609939 6,7 V 4 C 6.0002445,3.5390061 5.4609939,2.9997555 5,3 H 3 C 2.5390061,2.9997555 1.9997555,3.5390061 2,4 v 4 c -4.15e-5,5.069836 3.9301639,9.000041 9,9 h 4 c 0.460994,2.45e-4 1.000245,-0.539006 1,-1 v -2 c 2.45e-4,-0.460994 -0.539006,-1.000245 -1,-1 z"
|
||||||
|
id="path226"
|
||||||
|
style="fill:#73d216;fill-opacity:1;stroke:none;stroke-width:0.99999994"
|
||||||
|
sodipodi:nodetypes="ccccccccccccccccc" />
|
||||||
|
<path
|
||||||
|
style="opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#73d216;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill"
|
||||||
|
d="m 9,7 c 2,0 3,1 3,3"
|
||||||
|
id="path3644"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
sodipodi:nodetypes="cc" />
|
||||||
|
<path
|
||||||
|
sodipodi:nodetypes="cc"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="path3646"
|
||||||
|
d="m 9,4 c 4,0 6,2 6,6"
|
||||||
|
style="opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#73d216;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill" />
|
||||||
|
</svg>
|
After Width: | Height: | Size: 3.6 KiB |
99
src/assets/icons/call-in.svg
Normal file
|
@ -0,0 +1,99 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1826"
|
||||||
|
sodipodi:docname="call-in.svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
inkscape:export-filename="/home/cloud/Code/Carbon/src/assets/icons/call-out-accepted.svg.png"
|
||||||
|
inkscape:export-xdpi="96"
|
||||||
|
inkscape:export-ydpi="96"
|
||||||
|
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)">
|
||||||
|
<metadata
|
||||||
|
id="metadata1830">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
|
<dc:format>image/svg+xml</dc:format>
|
||||||
|
<dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||||
|
<dc:title>free-icons-solid</dc:title>
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
<sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1440"
|
||||||
|
inkscape:window-height="879"
|
||||||
|
id="namedview1828"
|
||||||
|
showgrid="true"
|
||||||
|
inkscape:snap-global="true"
|
||||||
|
inkscape:snap-bbox="false"
|
||||||
|
inkscape:snap-bbox-edge-midpoints="true"
|
||||||
|
inkscape:bbox-nodes="true"
|
||||||
|
inkscape:bbox-paths="false"
|
||||||
|
inkscape:object-paths="false"
|
||||||
|
inkscape:snap-intersection-paths="true"
|
||||||
|
inkscape:snap-midpoints="false"
|
||||||
|
inkscape:snap-smooth-nodes="false"
|
||||||
|
showguides="true"
|
||||||
|
inkscape:zoom="16"
|
||||||
|
inkscape:cx="9.4645773"
|
||||||
|
inkscape:cy="12.891863"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg1826">
|
||||||
|
<inkscape:grid
|
||||||
|
type="xygrid"
|
||||||
|
id="grid2773"
|
||||||
|
originx="-158"
|
||||||
|
originy="-1412" />
|
||||||
|
</sodipodi:namedview>
|
||||||
|
<defs
|
||||||
|
id="defs4">
|
||||||
|
<style
|
||||||
|
id="style2">.a{fill:#757575;}.b{fill:#b4b4b4;}.c{fill:#767676;}</style>
|
||||||
|
</defs>
|
||||||
|
<title
|
||||||
|
id="title6">free-icons-solid</title>
|
||||||
|
<path
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
class="a"
|
||||||
|
d="m 15,15 h -3 c -0.460994,-2.45e-4 -1.000244,0.539006 -1,1 l 0.0029,0.66952 C 7.75933,16.711832 4.2952877,13.24779 4.3376001,10.004177 L 5,10 C 5.4609939,10.000244 6.0002445,9.4609939 6,9 V 6 C 6.0002445,5.5390061 5.4609939,4.9997555 5,5 H 3 C 2.5390061,4.9997555 1.9997555,5.5390061 2,6 v 4 c -4.15e-5,5.069836 3.9301639,9.000041 9,9 h 4 c 0.460994,2.45e-4 1.000245,-0.539006 1,-1 v -2 c 2.45e-4,-0.460994 -0.539006,-1.000245 -1,-1 z"
|
||||||
|
id="path226"
|
||||||
|
style="fill:#d591c6;fill-opacity:1;stroke:none;stroke-width:0.99999994"
|
||||||
|
sodipodi:nodetypes="ccccccccccccccccc" />
|
||||||
|
<g
|
||||||
|
id="g2813"
|
||||||
|
style="stroke:#d591c6;stroke-opacity:1"
|
||||||
|
transform="matrix(1,0,0,-1,0,13.004142)">
|
||||||
|
<path
|
||||||
|
sodipodi:nodetypes="ccc"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="path2807"
|
||||||
|
d="M 13,11 V 2 l -3,3"
|
||||||
|
style="opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#d591c6;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill" />
|
||||||
|
<path
|
||||||
|
sodipodi:nodetypes="cc"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="path2809"
|
||||||
|
d="m 13,2 3,3"
|
||||||
|
style="opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#d591c6;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 3.7 KiB |
98
src/assets/icons/call-out.svg
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1826"
|
||||||
|
sodipodi:docname="call-out.svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
inkscape:export-filename="/home/cloud/Code/Carbon/src/assets/icons/call-out-accepted.svg.png"
|
||||||
|
inkscape:export-xdpi="96"
|
||||||
|
inkscape:export-ydpi="96"
|
||||||
|
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)">
|
||||||
|
<metadata
|
||||||
|
id="metadata1830">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
|
<dc:format>image/svg+xml</dc:format>
|
||||||
|
<dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||||
|
<dc:title>free-icons-solid</dc:title>
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
<sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1440"
|
||||||
|
inkscape:window-height="879"
|
||||||
|
id="namedview1828"
|
||||||
|
showgrid="true"
|
||||||
|
inkscape:snap-global="true"
|
||||||
|
inkscape:snap-bbox="false"
|
||||||
|
inkscape:snap-bbox-edge-midpoints="true"
|
||||||
|
inkscape:bbox-nodes="true"
|
||||||
|
inkscape:bbox-paths="false"
|
||||||
|
inkscape:object-paths="false"
|
||||||
|
inkscape:snap-intersection-paths="true"
|
||||||
|
inkscape:snap-midpoints="false"
|
||||||
|
inkscape:snap-smooth-nodes="false"
|
||||||
|
showguides="true"
|
||||||
|
inkscape:zoom="16"
|
||||||
|
inkscape:cx="9.4645773"
|
||||||
|
inkscape:cy="12.891863"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg1826">
|
||||||
|
<inkscape:grid
|
||||||
|
type="xygrid"
|
||||||
|
id="grid2773"
|
||||||
|
originx="-158"
|
||||||
|
originy="-1412" />
|
||||||
|
</sodipodi:namedview>
|
||||||
|
<defs
|
||||||
|
id="defs4">
|
||||||
|
<style
|
||||||
|
id="style2">.a{fill:#757575;}.b{fill:#b4b4b4;}.c{fill:#767676;}</style>
|
||||||
|
</defs>
|
||||||
|
<title
|
||||||
|
id="title6">free-icons-solid</title>
|
||||||
|
<path
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
class="a"
|
||||||
|
d="m 15,15 h -3 c -0.460994,-2.45e-4 -1.000244,0.539006 -1,1 l 0.0029,0.66952 C 7.75933,16.711832 4.2952877,13.24779 4.3376001,10.004177 L 5,10 C 5.4609939,10.000244 6.0002445,9.4609939 6,9 V 6 C 6.0002445,5.5390061 5.4609939,4.9997555 5,5 H 3 C 2.5390061,4.9997555 1.9997555,5.5390061 2,6 v 4 c -4.15e-5,5.069836 3.9301639,9.000041 9,9 h 4 c 0.460994,2.45e-4 1.000245,-0.539006 1,-1 v -2 c 2.45e-4,-0.460994 -0.539006,-1.000245 -1,-1 z"
|
||||||
|
id="path226"
|
||||||
|
style="fill:#d591c6;fill-opacity:1;stroke:none;stroke-width:0.99999994"
|
||||||
|
sodipodi:nodetypes="ccccccccccccccccc" />
|
||||||
|
<g
|
||||||
|
id="g2813"
|
||||||
|
style="stroke:#d591c6;stroke-opacity:1">
|
||||||
|
<path
|
||||||
|
sodipodi:nodetypes="ccc"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="path2807"
|
||||||
|
d="M 13,11 V 2 l -3,3"
|
||||||
|
style="opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#d591c6;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill" />
|
||||||
|
<path
|
||||||
|
sodipodi:nodetypes="cc"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="path2809"
|
||||||
|
d="m 13,2 3,3"
|
||||||
|
style="opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#d591c6;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 3.7 KiB |
98
src/assets/icons/call-rejected.svg
Normal file
|
@ -0,0 +1,98 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1826"
|
||||||
|
sodipodi:docname="call-rejected.svg"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
inkscape:export-filename="/home/cloud/Code/Carbon/src/assets/icons/call-out-accepted.svg.png"
|
||||||
|
inkscape:export-xdpi="96"
|
||||||
|
inkscape:export-ydpi="96"
|
||||||
|
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)">
|
||||||
|
<metadata
|
||||||
|
id="metadata1830">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
|
<dc:format>image/svg+xml</dc:format>
|
||||||
|
<dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||||
|
<dc:title>free-icons-solid</dc:title>
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
<sodipodi:namedview
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1"
|
||||||
|
objecttolerance="10"
|
||||||
|
gridtolerance="10"
|
||||||
|
guidetolerance="10"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:window-width="1440"
|
||||||
|
inkscape:window-height="879"
|
||||||
|
id="namedview1828"
|
||||||
|
showgrid="true"
|
||||||
|
inkscape:snap-global="true"
|
||||||
|
inkscape:snap-bbox="false"
|
||||||
|
inkscape:snap-bbox-edge-midpoints="true"
|
||||||
|
inkscape:bbox-nodes="true"
|
||||||
|
inkscape:bbox-paths="false"
|
||||||
|
inkscape:object-paths="false"
|
||||||
|
inkscape:snap-intersection-paths="true"
|
||||||
|
inkscape:snap-midpoints="false"
|
||||||
|
inkscape:snap-smooth-nodes="false"
|
||||||
|
showguides="true"
|
||||||
|
inkscape:zoom="22.627417"
|
||||||
|
inkscape:cx="12.65156"
|
||||||
|
inkscape:cy="10.798274"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg1826">
|
||||||
|
<inkscape:grid
|
||||||
|
type="xygrid"
|
||||||
|
id="grid2773"
|
||||||
|
originx="-158"
|
||||||
|
originy="-1412" />
|
||||||
|
</sodipodi:namedview>
|
||||||
|
<defs
|
||||||
|
id="defs4">
|
||||||
|
<style
|
||||||
|
id="style2">.a{fill:#757575;}.b{fill:#b4b4b4;}.c{fill:#767676;}</style>
|
||||||
|
</defs>
|
||||||
|
<title
|
||||||
|
id="title6">free-icons-solid</title>
|
||||||
|
<path
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
class="a"
|
||||||
|
d="m 15,13 h -3 c -0.460994,-2.45e-4 -1.000244,0.539006 -1,1 l 0.0029,0.66952 C 7.75933,14.711832 4.2952877,11.24779 4.3376001,8.004177 L 5,8 C 5.4609939,8.000244 6.0002445,7.4609939 6,7 V 4 C 6.0002445,3.5390061 5.4609939,2.9997555 5,3 H 3 C 2.5390061,2.9997555 1.9997555,3.5390061 2,4 v 4 c -4.15e-5,5.069836 3.9301639,9.000041 9,9 h 4 c 0.460994,2.45e-4 1.000245,-0.539006 1,-1 v -2 c 2.45e-4,-0.460994 -0.539006,-1.000245 -1,-1 z"
|
||||||
|
id="path226"
|
||||||
|
style="fill:#f43f3f;fill-opacity:1;stroke:none;stroke-width:0.99999994"
|
||||||
|
sodipodi:nodetypes="ccccccccccccccccc" />
|
||||||
|
<g
|
||||||
|
id="g2927"
|
||||||
|
style="stroke:#f43f3f;stroke-opacity:1">
|
||||||
|
<path
|
||||||
|
sodipodi:nodetypes="cc"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="path2872"
|
||||||
|
d="M 11,8 16,3"
|
||||||
|
style="opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#f43f3f;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill" />
|
||||||
|
<path
|
||||||
|
style="opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#f43f3f;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill"
|
||||||
|
d="M 16,8 11,3"
|
||||||
|
id="path2923"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
sodipodi:nodetypes="cc" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 3.7 KiB |
81
src/assets/icons/invite-event.svg
Normal file
|
@ -0,0 +1,81 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 5.2916665 5.2916668"
|
||||||
|
version="1.1"
|
||||||
|
id="svg27"
|
||||||
|
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
|
||||||
|
sodipodi:docname="invite-event.svg">
|
||||||
|
<defs
|
||||||
|
id="defs21" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="base"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1.0"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:zoom="16"
|
||||||
|
inkscape:cx="12.866591"
|
||||||
|
inkscape:cy="7.092849"
|
||||||
|
inkscape:document-units="px"
|
||||||
|
inkscape:current-layer="layer1"
|
||||||
|
showgrid="true"
|
||||||
|
units="px"
|
||||||
|
inkscape:window-width="1440"
|
||||||
|
inkscape:window-height="879"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:snap-global="false"
|
||||||
|
inkscape:snap-bbox="true"
|
||||||
|
inkscape:bbox-nodes="true">
|
||||||
|
<inkscape:grid
|
||||||
|
type="xygrid"
|
||||||
|
id="grid26" />
|
||||||
|
</sodipodi:namedview>
|
||||||
|
<metadata
|
||||||
|
id="metadata24">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
|
<dc:format>image/svg+xml</dc:format>
|
||||||
|
<dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||||
|
<dc:title></dc:title>
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(0,-291.70832)">
|
||||||
|
<path
|
||||||
|
sodipodi:type="star"
|
||||||
|
style="opacity:1;fill:#fce94f;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.50955456;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill"
|
||||||
|
id="path1026"
|
||||||
|
sodipodi:sides="4"
|
||||||
|
sodipodi:cx="2.5607762"
|
||||||
|
sodipodi:cy="294.50937"
|
||||||
|
sodipodi:r1="2.1649818"
|
||||||
|
sodipodi:r2="0.86599272"
|
||||||
|
sodipodi:arg1="0.78539816"
|
||||||
|
sodipodi:arg2="1.5707963"
|
||||||
|
inkscape:flatsided="false"
|
||||||
|
inkscape:rounded="0"
|
||||||
|
inkscape:randomized="0"
|
||||||
|
d="m 4.0916496,296.04024 -1.5308733,-0.66488 -1.5308734,0.66488 0.6648806,-1.53087 -0.6648806,-1.53087 1.5308733,0.66488 1.5308734,-0.66488 -0.6648806,1.53087 z"
|
||||||
|
transform="matrix(0.73526681,0.7333768,-0.7333768,0.73526681,216.88378,75.810398)" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2.7 KiB |
|
@ -25,9 +25,9 @@
|
||||||
borderopacity="1.0"
|
borderopacity="1.0"
|
||||||
inkscape:pageopacity="0.0"
|
inkscape:pageopacity="0.0"
|
||||||
inkscape:pageshadow="2"
|
inkscape:pageshadow="2"
|
||||||
inkscape:zoom="1"
|
inkscape:zoom="11.313708"
|
||||||
inkscape:cx="15.649008"
|
inkscape:cx="-4.2728481"
|
||||||
inkscape:cy="8.3751893"
|
inkscape:cy="-2.1951295"
|
||||||
inkscape:document-units="px"
|
inkscape:document-units="px"
|
||||||
inkscape:current-layer="layer1"
|
inkscape:current-layer="layer1"
|
||||||
showgrid="true"
|
showgrid="true"
|
||||||
|
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
80
src/assets/icons/leave-event.svg
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 5.2916665 5.2916668"
|
||||||
|
version="1.1"
|
||||||
|
id="svg27"
|
||||||
|
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
|
||||||
|
sodipodi:docname="leave-event.svg">
|
||||||
|
<defs
|
||||||
|
id="defs21" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="base"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1.0"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:zoom="11.313708"
|
||||||
|
inkscape:cx="-4.2728481"
|
||||||
|
inkscape:cy="-2.1951295"
|
||||||
|
inkscape:document-units="px"
|
||||||
|
inkscape:current-layer="layer1"
|
||||||
|
showgrid="true"
|
||||||
|
units="px"
|
||||||
|
inkscape:window-width="1440"
|
||||||
|
inkscape:window-height="879"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1">
|
||||||
|
<inkscape:grid
|
||||||
|
type="xygrid"
|
||||||
|
id="grid26" />
|
||||||
|
</sodipodi:namedview>
|
||||||
|
<metadata
|
||||||
|
id="metadata24">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
|
<dc:format>image/svg+xml</dc:format>
|
||||||
|
<dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||||
|
<dc:title></dc:title>
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(0,-291.70832)">
|
||||||
|
<path
|
||||||
|
style="opacity:1;fill:#e2e2e2;fill-opacity:1;fill-rule:nonzero;stroke:#f43f3f;stroke-width:0.52916664;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
|
||||||
|
d="M 4.4979167,294.35416 H 0.79374997"
|
||||||
|
id="path28"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
sodipodi:nodetypes="cc" />
|
||||||
|
<path
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="path30"
|
||||||
|
d="m 2.1166667,293.03124 -1.32291673,1.32292"
|
||||||
|
style="opacity:1;fill:#e2e2e2;fill-opacity:1;fill-rule:nonzero;stroke:#f43f3f;stroke-width:0.52916664;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
|
||||||
|
sodipodi:nodetypes="cc" />
|
||||||
|
<path
|
||||||
|
sodipodi:nodetypes="cc"
|
||||||
|
style="opacity:1;fill:#e2e2e2;fill-opacity:1;fill-rule:nonzero;stroke:#f43f3f;stroke-width:0.52916664;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
|
||||||
|
d="m 0.79374997,294.35416 1.32291673,1.32291"
|
||||||
|
id="path32"
|
||||||
|
inkscape:connector-curvature="0" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 2.9 KiB |
83
src/assets/icons/profile-event.svg
Normal file
|
@ -0,0 +1,83 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
xmlns:dc="http://purl.org/dc/elements/1.1/"
|
||||||
|
xmlns:cc="http://creativecommons.org/ns#"
|
||||||
|
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 5.2916665 5.2916668"
|
||||||
|
version="1.1"
|
||||||
|
id="svg27"
|
||||||
|
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
|
||||||
|
sodipodi:docname="profile-event.svg">
|
||||||
|
<defs
|
||||||
|
id="defs21" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="base"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#666666"
|
||||||
|
borderopacity="1.0"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pageshadow="2"
|
||||||
|
inkscape:zoom="16"
|
||||||
|
inkscape:cx="8.674554"
|
||||||
|
inkscape:cy="12.76461"
|
||||||
|
inkscape:document-units="px"
|
||||||
|
inkscape:current-layer="layer1"
|
||||||
|
showgrid="true"
|
||||||
|
units="px"
|
||||||
|
inkscape:window-width="1440"
|
||||||
|
inkscape:window-height="879"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:snap-global="true"
|
||||||
|
inkscape:snap-bbox="true"
|
||||||
|
inkscape:bbox-nodes="true">
|
||||||
|
<inkscape:grid
|
||||||
|
type="xygrid"
|
||||||
|
id="grid26" />
|
||||||
|
</sodipodi:namedview>
|
||||||
|
<metadata
|
||||||
|
id="metadata24">
|
||||||
|
<rdf:RDF>
|
||||||
|
<cc:Work
|
||||||
|
rdf:about="">
|
||||||
|
<dc:format>image/svg+xml</dc:format>
|
||||||
|
<dc:type
|
||||||
|
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||||
|
<dc:title></dc:title>
|
||||||
|
</cc:Work>
|
||||||
|
</rdf:RDF>
|
||||||
|
</metadata>
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1"
|
||||||
|
transform="translate(0,-291.70832)">
|
||||||
|
<path
|
||||||
|
style="opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#729fcf;stroke-width:0.52916664;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill"
|
||||||
|
d="m 2.5761745,295.93007 c -0.6374174,0.0773 -1.2586148,-0.23706 -1.5739225,-0.79639 -0.31531561,-0.55933 -0.26263464,-1.25353 0.1334534,-1.75888 0.3960878,-0.50535 1.057602,-0.72235 1.6760616,-0.54979 0.6184669,0.17255 1.0674318,0.73341 1.3352208,1.26428"
|
||||||
|
id="path941"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
sodipodi:nodetypes="csscc" />
|
||||||
|
<path
|
||||||
|
style="opacity:1;fill:#729fcf;fill-opacity:1;fill-rule:nonzero;stroke:#729fcf;stroke-width:0.52916664;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill"
|
||||||
|
d="M 4.1469878,294.08929 3.0515179,293.89345"
|
||||||
|
id="path943"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
sodipodi:nodetypes="cc" />
|
||||||
|
<path
|
||||||
|
sodipodi:nodetypes="cc"
|
||||||
|
inkscape:connector-curvature="0"
|
||||||
|
id="path948"
|
||||||
|
d="m 4.2827903,292.91012 -0.1358025,1.17917"
|
||||||
|
style="opacity:1;fill:#729fcf;fill-opacity:1;fill-rule:nonzero;stroke:#729fcf;stroke-width:0.52916664;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 3.2 KiB |
14
src/home.pug
|
@ -33,19 +33,15 @@ doctype html
|
||||||
html
|
html
|
||||||
head
|
head
|
||||||
meta(charset="utf-8")
|
meta(charset="utf-8")
|
||||||
|
title Carbon
|
||||||
// var static = !{JSON.stringify([...static.entries()].reduce((a, c) => (a[c[0]] = getRelative(c[1]), a), {}))}
|
// var static = !{JSON.stringify([...static.entries()].reduce((a, c) => (a[c[0]] = getRelative(c[1]), a), {}))}
|
||||||
script
|
script
|
||||||
| var staticFiles = new Map(
|
| var staticFiles = new Map(
|
||||||
!= JSON.stringify([...static.keys()].map(k => [k, getStatic(k)]))
|
!= JSON.stringify([...static.keys()].map(k => [k, getStatic(k)]))
|
||||||
| )
|
| )
|
||||||
link(rel="stylesheet" type="text/css" href=getStatic("/sass/main.sass"))
|
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/main.js"))
|
||||||
script(type="module" src=getStatic("/js/chat-input.js"))
|
body.show-focus
|
||||||
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
|
|
||||||
body
|
|
||||||
main.main
|
main.main
|
||||||
.c-groups
|
.c-groups
|
||||||
.c-groups__display#c-groups-display
|
.c-groups__display#c-groups-display
|
||||||
|
@ -53,7 +49,9 @@ html
|
||||||
.c-groups__container#c-groups-list
|
.c-groups__container#c-groups-list
|
||||||
.c-rooms#c-rooms
|
.c-rooms#c-rooms
|
||||||
.c-chat
|
.c-chat
|
||||||
|
.c-chat-banner#c-chat-banner
|
||||||
.c-chat__messages#c-chat-messages
|
.c-chat__messages#c-chat-messages
|
||||||
.c-chat__inner#c-chat
|
.c-chat__inner#c-chat
|
||||||
.c-chat-input
|
.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
|
||||||
|
.c-typing#c-typing
|
||||||
|
|
|
@ -1,366 +0,0 @@
|
||||||
import {ElemJS, ejs} from $to_relative "/js/basic.js"
|
|
||||||
import {Subscribable} from $to_relative "/js/store/Subscribable.js"
|
|
||||||
import {store} from $to_relative "/js/store/store.js"
|
|
||||||
import {Anchor} from $to_relative "/js/Anchor.js"
|
|
||||||
import * as lsm from $to_relative "/js/lsm.js"
|
|
||||||
import {resolveMxc} from $to_relative "/js/functions.js"
|
|
||||||
|
|
||||||
const dateFormatter = Intl.DateTimeFormat("default", {hour: "numeric", minute: "numeric", day: "numeric", month: "short", year: "numeric"})
|
|
||||||
|
|
||||||
let sentIndex = 0
|
|
||||||
|
|
||||||
function getTxnId() {
|
|
||||||
return Date.now() + (sentIndex++)
|
|
||||||
}
|
|
||||||
|
|
||||||
function eventSearch(list, event, min = 0, max = -1) {
|
|
||||||
if (list.length === 0) return {success: false, i: 0}
|
|
||||||
|
|
||||||
if (max === -1) max = list.length - 1
|
|
||||||
let mid = Math.floor((max + min) / 2)
|
|
||||||
// success condition
|
|
||||||
if (list[mid] && list[mid].data.event_id === event.data.event_id) return {success: true, i: mid}
|
|
||||||
// failed condition
|
|
||||||
if (min >= max) {
|
|
||||||
while (mid !== -1 && (!list[mid] || list[mid].data.origin_server_ts > event.data.origin_server_ts)) mid--
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
i: mid + 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// recurse (below)
|
|
||||||
if (list[mid].data.origin_server_ts > event.data.origin_server_ts) return eventSearch(list, event, min, mid-1)
|
|
||||||
// recurse (above)
|
|
||||||
else return eventSearch(list, event, mid+1, max)
|
|
||||||
}
|
|
||||||
|
|
||||||
class Event extends ElemJS {
|
|
||||||
constructor(data) {
|
|
||||||
super("div")
|
|
||||||
this.class("c-message")
|
|
||||||
this.data = null
|
|
||||||
this.group = null
|
|
||||||
this.editedAt = null
|
|
||||||
this.update(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
// predicates
|
|
||||||
|
|
||||||
canGroup() {
|
|
||||||
return this.data.type === "m.room.message"
|
|
||||||
}
|
|
||||||
|
|
||||||
// operations
|
|
||||||
|
|
||||||
setGroup(group) {
|
|
||||||
this.group = group
|
|
||||||
}
|
|
||||||
|
|
||||||
setEdited(time) {
|
|
||||||
this.editedAt = time
|
|
||||||
this.render()
|
|
||||||
}
|
|
||||||
|
|
||||||
update(data) {
|
|
||||||
this.data = data
|
|
||||||
this.render()
|
|
||||||
}
|
|
||||||
|
|
||||||
removeEvent() {
|
|
||||||
if (this.group) this.group.removeEvent(this)
|
|
||||||
else this.remove()
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
this.element.classList[this.data.pending ? "add" : "remove"]("c-message--pending")
|
|
||||||
if (this.data.type === "m.room.message") {
|
|
||||||
this.text(this.data.content.body)
|
|
||||||
} else if (this.data.type === "m.room.member") {
|
|
||||||
if (this.data.content.membership === "join") {
|
|
||||||
this.child(ejs("i").text("joined the room"))
|
|
||||||
} else {
|
|
||||||
this.child(ejs("i").text("left the room"))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
this.child(ejs("i").text(`Unsupported event type ${this.data.type}`))
|
|
||||||
}
|
|
||||||
if (this.editedAt) {
|
|
||||||
this.child(ejs("span").class("c-message__edited").text("(edited)").attribute("title", "at " + dateFormatter.format(this.editedAt)))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
|
||||||
constructor(reactive, list) {
|
|
||||||
super("div")
|
|
||||||
this.class("c-message-group")
|
|
||||||
this.reactive = reactive
|
|
||||||
this.list = list
|
|
||||||
this.data = {
|
|
||||||
sender: list[0].data.sender,
|
|
||||||
origin_server_ts: list[0].data.origin_server_ts
|
|
||||||
}
|
|
||||||
this.sender = new Sender(this.reactive.id, this.data.sender)
|
|
||||||
this.child(
|
|
||||||
this.sender.avatar,
|
|
||||||
this.messages = ejs("div").class("c-message-group__messages").child(
|
|
||||||
ejs("div").class("c-message-group__intro").child(
|
|
||||||
this.sender.name,
|
|
||||||
ejs("div").class("c-message-group__date").text(dateFormatter.format(this.data.origin_server_ts))
|
|
||||||
),
|
|
||||||
...this.list
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
addEvent(event) {
|
|
||||||
const index = eventSearch(this.list, event).i
|
|
||||||
event.setGroup(this)
|
|
||||||
this.list.splice(index, 0, event)
|
|
||||||
this.messages.childAt(index + 1, event)
|
|
||||||
}
|
|
||||||
|
|
||||||
removeEvent(event) {
|
|
||||||
const search = eventSearch(this.list, event)
|
|
||||||
if (!search.success) throw new Error(`Event ${event.data.event_id} not found in this group`)
|
|
||||||
const index = search.i
|
|
||||||
// actually remove the event
|
|
||||||
this.list.splice(index, 1)
|
|
||||||
event.remove() // should get everything else
|
|
||||||
if (this.list.length === 0) this.reactive.removeGroup(this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ReactiveTimeline extends ElemJS {
|
|
||||||
constructor(id, list) {
|
|
||||||
super("div")
|
|
||||||
this.class("c-event-groups")
|
|
||||||
this.id = id
|
|
||||||
this.list = list
|
|
||||||
this.render()
|
|
||||||
}
|
|
||||||
|
|
||||||
addEvent(event) {
|
|
||||||
const search = eventSearch(this.list, event)
|
|
||||||
// console.log(search, this.list.map(l => l.data.sender), event.data)
|
|
||||||
if (!search.success && search.i >= 1) this.tryAddGroups(event, [search.i-1, search.i])
|
|
||||||
else this.tryAddGroups(event, [search.i])
|
|
||||||
}
|
|
||||||
|
|
||||||
tryAddGroups(event, indices) {
|
|
||||||
const success = indices.some(i => {
|
|
||||||
if (!this.list[i]) {
|
|
||||||
// if (printed++ < 100) console.log("tryadd success, created group")
|
|
||||||
const group = new EventGroup(this, [event])
|
|
||||||
this.list.splice(i, 0, group)
|
|
||||||
this.childAt(i, group)
|
|
||||||
event.setGroup(group)
|
|
||||||
return true
|
|
||||||
} else if (this.list[i] && this.list[i].data.sender === event.data.sender) {
|
|
||||||
// if (printed++ < 100) console.log("tryadd success, using existing group")
|
|
||||||
this.list[i].addEvent(event)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (!success) console.log("tryadd failure", indices, this.list.map(l => l.data.sender), event.data)
|
|
||||||
}
|
|
||||||
|
|
||||||
removeGroup(group) {
|
|
||||||
const index = this.list.indexOf(group)
|
|
||||||
this.list.splice(index, 1)
|
|
||||||
group.remove() // should get everything else
|
|
||||||
}
|
|
||||||
|
|
||||||
render() {
|
|
||||||
this.clearChildren()
|
|
||||||
this.list.forEach(group => this.child(group))
|
|
||||||
this.anchor = new Anchor()
|
|
||||||
this.child(this.anchor)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class Timeline extends Subscribable {
|
|
||||||
constructor(room) {
|
|
||||||
super()
|
|
||||||
Object.assign(this.events, {
|
|
||||||
beforeChange: [],
|
|
||||||
afterChange: []
|
|
||||||
})
|
|
||||||
Object.assign(this.eventDeps, {
|
|
||||||
beforeChange: [],
|
|
||||||
afterChange: []
|
|
||||||
})
|
|
||||||
this.room = room
|
|
||||||
this.id = this.room.id
|
|
||||||
this.list = []
|
|
||||||
this.map = new Map()
|
|
||||||
this.reactiveTimeline = new ReactiveTimeline(this.id, [])
|
|
||||||
this.latest = 0
|
|
||||||
this.pending = new Set()
|
|
||||||
}
|
|
||||||
|
|
||||||
updateStateEvents(events) {
|
|
||||||
for (const eventData of events) {
|
|
||||||
let id = eventData.event_id
|
|
||||||
if (eventData.type === "m.room.member") {
|
|
||||||
// update members
|
|
||||||
if (eventData.membership !== "leave") {
|
|
||||||
this.room.members.get(eventData.state_key).set(eventData)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
updateEvents(events) {
|
|
||||||
this.broadcast("beforeChange")
|
|
||||||
// handle state events
|
|
||||||
this.updateStateEvents(events)
|
|
||||||
for (const eventData of events) {
|
|
||||||
// set variables
|
|
||||||
this.latest = Math.max(this.latest, eventData.origin_server_ts)
|
|
||||||
let id = eventData.event_id
|
|
||||||
// handle local echoes
|
|
||||||
if (eventData.sender === lsm.get("mx_user_id") && eventData.content && this.pending.has(eventData.content["chat.carbon.message.pending_id"])) {
|
|
||||||
const target = this.map.get(eventData.content["chat.carbon.message.pending_id"])
|
|
||||||
this.map.set(id, target)
|
|
||||||
this.map.delete(eventData.content["chat.carbon.message.pending_id"])
|
|
||||||
}
|
|
||||||
// handle timeline events
|
|
||||||
if (this.map.has(id)) {
|
|
||||||
// update existing event
|
|
||||||
this.map.get(id).update(eventData)
|
|
||||||
} else {
|
|
||||||
// skip displaying events that we don't know how to
|
|
||||||
if (eventData.type === "m.reaction") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// skip redacted events
|
|
||||||
if (eventData.unsigned && eventData.unsigned.redacted_by) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// handle redactions
|
|
||||||
if (eventData.type === "m.room.redaction") {
|
|
||||||
if (this.map.has(eventData.redacts)) this.map.get(eventData.redacts).removeEvent()
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// handle edits
|
|
||||||
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)) {
|
|
||||||
const event = this.map.get(replaces)
|
|
||||||
event.data.content = eventData.content["m.new_content"]
|
|
||||||
event.setEdited(eventData.origin_server_ts)
|
|
||||||
event.update(event.data)
|
|
||||||
continue
|
|
||||||
} else {
|
|
||||||
// uhhhhhhh
|
|
||||||
console.error(`want to replace event ${replaces} with ${eventData.id} but replaced event not found`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// add new event
|
|
||||||
const event = new Event(eventData)
|
|
||||||
this.map.set(id, event)
|
|
||||||
this.reactiveTimeline.addEvent(event)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.broadcast("afterChange")
|
|
||||||
}
|
|
||||||
|
|
||||||
removeEvent(id) {
|
|
||||||
if (!this.map.has(id)) throw new Error(`Tried to delete event ID ${id} which does not exist`)
|
|
||||||
this.map.get(id).removeEvent()
|
|
||||||
this.map.delete(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
getTimeline() {
|
|
||||||
return this.reactiveTimeline
|
|
||||||
}
|
|
||||||
|
|
||||||
send(body) {
|
|
||||||
const tx = getTxnId()
|
|
||||||
const id = `pending$${tx}`
|
|
||||||
this.pending.add(id)
|
|
||||||
const content = {
|
|
||||||
msgtype: "m.text",
|
|
||||||
body,
|
|
||||||
"chat.carbon.message.pending_id": id
|
|
||||||
}
|
|
||||||
const fakeEvent = {
|
|
||||||
type: "m.room.message",
|
|
||||||
origin_server_ts: Date.now(),
|
|
||||||
event_id: id,
|
|
||||||
sender: lsm.get("mx_user_id"),
|
|
||||||
content,
|
|
||||||
pending: true
|
|
||||||
}
|
|
||||||
this.updateEvents([fakeEvent])
|
|
||||||
return fetch(`${lsm.get("domain")}/_matrix/client/r0/rooms/${this.id}/send/m.room.message/${tx}?access_token=${lsm.get("access_token")}`, {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify(content),
|
|
||||||
headers: {
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
}
|
|
||||||
|
|
||||||
export {Timeline}
|
|
|
@ -1,4 +1,4 @@
|
||||||
import {ElemJS} from $to_relative "/js/basic.js"
|
const {ElemJS} = require("./basic.js")
|
||||||
|
|
||||||
class Anchor extends ElemJS {
|
class Anchor extends ElemJS {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
@ -12,4 +12,4 @@ class Anchor extends ElemJS {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {Anchor}
|
module.exports = {Anchor}
|
|
@ -19,12 +19,12 @@ const qa = s => document.querySelectorAll(s);
|
||||||
*/
|
*/
|
||||||
class ElemJS {
|
class ElemJS {
|
||||||
constructor(type) {
|
constructor(type) {
|
||||||
if (type instanceof HTMLElement) {
|
if (typeof type === "string") {
|
||||||
// If passed an existing element, bind to it
|
// Passed a tag name; create an element to bind to
|
||||||
this.bind(type);
|
|
||||||
} else {
|
|
||||||
// Otherwise, create a new detached element to bind to
|
|
||||||
this.bind(document.createElement(type));
|
this.bind(document.createElement(type));
|
||||||
|
} else {
|
||||||
|
// Passed an existing element; bind to it
|
||||||
|
this.bind(type);
|
||||||
}
|
}
|
||||||
this.children = [];
|
this.children = [];
|
||||||
}
|
}
|
||||||
|
@ -157,4 +157,4 @@ function ejs(tag) {
|
||||||
return new ElemJS(tag);
|
return new ElemJS(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {q, qa, ElemJS, ejs}
|
module.exports = {q, qa, ElemJS, ejs}
|
||||||
|
|
|
@ -1,28 +1,93 @@
|
||||||
import {q} from $to_relative "/js/basic.js"
|
const {q} = require("./basic.js")
|
||||||
import {store} from $to_relative "/js/store/store.js"
|
const {store} = require("./store/store.js")
|
||||||
import * as lsm from $to_relative "/js/lsm.js"
|
const lsm = require("./lsm.js")
|
||||||
import {chat} from $to_relative "/js/chat.js"
|
const {chat} = require("./chat.js")
|
||||||
|
const {toHTML} = require("discord-markdown")
|
||||||
|
|
||||||
const input = q("#c-chat-textarea")
|
const input = q("#c-chat-textarea")
|
||||||
|
|
||||||
|
class TypingManager {
|
||||||
|
constructor() {
|
||||||
|
/** How long to appear to type for. */
|
||||||
|
this.time = 20000
|
||||||
|
/** How long before the end of the timeout to send the request again. */
|
||||||
|
this.margin = 5000
|
||||||
|
/** The room that we're typing in. We can semantically only type in one room at a time. */
|
||||||
|
this.typingRoom = null
|
||||||
|
this.timeout = null
|
||||||
|
}
|
||||||
|
|
||||||
|
request(id, typing) {
|
||||||
|
const url = new URL(`${lsm.get("domain")}/_matrix/client/r0/rooms/${id}/typing/${lsm.get("mx_user_id")}`)
|
||||||
|
url.searchParams.set("access_token", lsm.get("access_token"))
|
||||||
|
const body = {typing}
|
||||||
|
if (typing) body.timeout = this.time
|
||||||
|
fetch(url.toString(), {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
schedule(id) {
|
||||||
|
this.request(id, true)
|
||||||
|
this.timeout = setTimeout(() => {
|
||||||
|
this.schedule(id)
|
||||||
|
}, this.time - this.margin)
|
||||||
|
}
|
||||||
|
|
||||||
|
update(id) {
|
||||||
|
if (id) { // typing somewhere
|
||||||
|
if (this.typingRoom === id) return // already typing, don't do anything
|
||||||
|
// state
|
||||||
|
this.typingRoom = id
|
||||||
|
// mark and schedule
|
||||||
|
this.schedule(id)
|
||||||
|
// add self to typing list now instead of waiting a round trip
|
||||||
|
const typing = store.rooms.get(id).value().timeline.typing
|
||||||
|
typing.edit(list => list.concat(lsm.get("mx_user_id")))
|
||||||
|
} else { // stopped typing
|
||||||
|
if (this.typingRoom) {
|
||||||
|
clearTimeout(this.timeout)
|
||||||
|
this.request(this.typingRoom, false)
|
||||||
|
}
|
||||||
|
this.typingRoom = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const typingManager = new TypingManager()
|
||||||
|
|
||||||
store.activeRoom.subscribe("changeSelf", () => {
|
store.activeRoom.subscribe("changeSelf", () => {
|
||||||
|
// stop typing. you semantically can't type in a room you're not in.
|
||||||
|
typingManager.update(null)
|
||||||
|
// focus input box
|
||||||
if (store.activeRoom.exists()) {
|
if (store.activeRoom.exists()) {
|
||||||
input.focus()
|
input.focus()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
input.addEventListener("keydown", event => {
|
input.addEventListener("keydown", event => {
|
||||||
|
if (!store.activeRoom.exists()) return
|
||||||
|
// send message?
|
||||||
if (event.key === "Enter" && !event.shiftKey && !event.ctrlKey) {
|
if (event.key === "Enter" && !event.shiftKey && !event.ctrlKey) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
const body = input.value
|
const body = input.value
|
||||||
send(input.value)
|
send(input.value)
|
||||||
|
typingManager.update(null) // stop typing
|
||||||
input.value = ""
|
input.value = ""
|
||||||
fixHeight()
|
fixHeight()
|
||||||
|
return
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
input.addEventListener("input", () => {
|
input.addEventListener("input", () => {
|
||||||
fixHeight()
|
fixHeight()
|
||||||
|
// set typing
|
||||||
|
if (input.value) {
|
||||||
|
typingManager.update(store.activeRoom.value().id)
|
||||||
|
} else {
|
||||||
|
typingManager.update(null)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
function fixHeight() {
|
function fixHeight() {
|
||||||
|
@ -33,5 +98,13 @@ function fixHeight() {
|
||||||
|
|
||||||
function send(body) {
|
function send(body) {
|
||||||
if (!store.activeRoom.exists()) return
|
if (!store.activeRoom.exists()) return
|
||||||
return store.activeRoom.value().timeline.send(body)
|
if (!body.trim().length) return
|
||||||
|
const content = {
|
||||||
|
msgtype: "m.text",
|
||||||
|
format: "org.matrix.custom.html",
|
||||||
|
body,
|
||||||
|
formatted_body: toHTML(body),
|
||||||
|
"chat.carbon.message.input_body": body
|
||||||
|
}
|
||||||
|
return store.activeRoom.value().timeline.send("m.room.message", content)
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import {ElemJS, q, ejs} from $to_relative "/js/basic.js"
|
const {ElemJS, q, ejs} = require("./basic.js")
|
||||||
import {store} from $to_relative "/js/store/store.js"
|
const {store} = require("./store/store.js")
|
||||||
|
|
||||||
const chatMessages = q("#c-chat-messages")
|
const chatMessages = q("#c-chat-messages")
|
||||||
|
|
||||||
|
@ -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()
|
||||||
|
@ -62,4 +79,4 @@ class Chat extends ElemJS {
|
||||||
|
|
||||||
const chat = new Chat()
|
const chat = new Chat()
|
||||||
|
|
||||||
export {chat}
|
module.exports = {chat}
|
||||||
|
|
3
src/js/date-formatter.js
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
const dateFormatter = Intl.DateTimeFormat("default", {hour: "numeric", minute: "numeric", day: "numeric", month: "short", year: "numeric"})
|
||||||
|
|
||||||
|
module.exports = {dateFormatter}
|
67
src/js/events/call.js
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
const {UngroupableEvent} = require("./event")
|
||||||
|
const {ejs} = require("../basic")
|
||||||
|
const lsm = require("../lsm")
|
||||||
|
const {extractDisplayName, resolveMxc, extractLocalpart} = require("../functions")
|
||||||
|
|
||||||
|
class CallEvent extends UngroupableEvent {
|
||||||
|
constructor(data) {
|
||||||
|
super(data)
|
||||||
|
this.class("c-message-event")
|
||||||
|
this.senderName = extractLocalpart(this.data.sender)
|
||||||
|
this.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
renderInner(iconURL, elements) {
|
||||||
|
this.clearChildren()
|
||||||
|
this.child(
|
||||||
|
ejs("div").class("c-message-event__inner").child(
|
||||||
|
iconURL ? ejs("img").class("c-message-event__icon").attribute("width", "20").attribute("height", "20").attribute("src", iconURL) : "",
|
||||||
|
...elements
|
||||||
|
)
|
||||||
|
)
|
||||||
|
super.render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CallInviteEvent extends CallEvent {
|
||||||
|
static canRender(eventData) {
|
||||||
|
return eventData.type === "m.call.invite"
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const icon = this.data.sender === lsm.get("mx_user_id") ? "static/call-out.svg" : "static/call-in.svg"
|
||||||
|
this.renderInner(icon, [
|
||||||
|
this.senderName,
|
||||||
|
" started a VOIP call, but Carbon doesn't support VOIP calls"
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CallAnswerEvent extends CallEvent {
|
||||||
|
static canRender(eventData) {
|
||||||
|
return eventData.type === "m.call.answer"
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this.renderInner("static/call-accepted.svg", [
|
||||||
|
this.senderName,
|
||||||
|
" answered the call"
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class CallHangupEvent extends CallEvent {
|
||||||
|
static canRender(eventData) {
|
||||||
|
return eventData.type === "m.call.hangup"
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const reason = this.data.content.reason === "invite_timeout" ? "missed the call" : "hung up the call"
|
||||||
|
this.renderInner("static/call-rejected.svg", [
|
||||||
|
this.senderName,
|
||||||
|
" " + reason
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = [CallInviteEvent, CallAnswerEvent, CallHangupEvent]
|
36
src/js/events/components.js
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
const {ElemJS} = require("../basic")
|
||||||
|
const {lazyLoad} = require("../lazy-load-module")
|
||||||
|
|
||||||
|
class HighlightedCode extends ElemJS {
|
||||||
|
constructor(element) {
|
||||||
|
super(element)
|
||||||
|
if (this.element.tagName === "PRE" && this.element.children.length === 1 && this.element.children[0].tagName === "CODE") {
|
||||||
|
// we shouldn't nest <code> inside <pre>. put the text in <pre> directly.
|
||||||
|
const code = this.element.children[0]
|
||||||
|
this.clearChildren()
|
||||||
|
while (code.firstChild) {
|
||||||
|
this.element.appendChild(code.firstChild)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let shouldHighlight = (
|
||||||
|
// if there are child _elements_, it's already formatted, we shouldn't mess that up
|
||||||
|
this.element.children.length === 0
|
||||||
|
/*
|
||||||
|
no need to highlight very short code blocks:
|
||||||
|
- content inside might not be code, some users still use code blocks
|
||||||
|
for plaintext quotes
|
||||||
|
- language detection will almost certainly be incorrect
|
||||||
|
- even if it's code and the language is detected, the user will
|
||||||
|
be able to mentally format small amounts of code themselves
|
||||||
|
|
||||||
|
feel free to change the threshold number
|
||||||
|
*/
|
||||||
|
&& this.element.textContent.length > 80
|
||||||
|
)
|
||||||
|
if (shouldHighlight) {
|
||||||
|
lazyLoad("https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@10/build/highlight.min.js").then(hljs => hljs.highlightBlock(this.element))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {HighlightedCode}
|
18
src/js/events/encrypted.js
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
const {GroupableEvent} = require("./event")
|
||||||
|
const {ejs} = require("../basic")
|
||||||
|
|
||||||
|
class EncryptedMessage extends GroupableEvent {
|
||||||
|
render() {
|
||||||
|
this.clearChildren()
|
||||||
|
this.child(
|
||||||
|
ejs("i").text("Carbon cannot render encrypted messages yet")
|
||||||
|
)
|
||||||
|
super.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
static canRender(eventData) {
|
||||||
|
return eventData.type === "m.room.encrypted"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = [EncryptedMessage]
|
72
src/js/events/event.js
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
const {ElemJS, ejs} = require("../basic")
|
||||||
|
const {dateFormatter} = require("../date-formatter")
|
||||||
|
const {SubscribeSet} = require("../store/subscribe_set.js")
|
||||||
|
|
||||||
|
class MatrixEvent extends ElemJS {
|
||||||
|
constructor(data) {
|
||||||
|
super("div")
|
||||||
|
this.data = null
|
||||||
|
this.group = null
|
||||||
|
this.editedAt = null
|
||||||
|
this.readBy = new SubscribeSet()
|
||||||
|
this.update(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// predicates
|
||||||
|
|
||||||
|
canGroup() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// operations
|
||||||
|
|
||||||
|
setGroup(group) {
|
||||||
|
this.group = group
|
||||||
|
}
|
||||||
|
|
||||||
|
setEdited(time) {
|
||||||
|
this.editedAt = time
|
||||||
|
this.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
update(data) {
|
||||||
|
this.data = data
|
||||||
|
this.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEvent() {
|
||||||
|
if (this.group) this.group.removeEvent(this)
|
||||||
|
else this.remove()
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this.element.classList[this.data.pending ? "add" : "remove"]("c-message--pending")
|
||||||
|
if (this.editedAt) {
|
||||||
|
this.child(ejs("span").class("c-message__edited").text("(edited)").attribute("title", "at " + dateFormatter.format(this.editedAt)))
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
static canRender(eventData) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class GroupableEvent extends MatrixEvent {
|
||||||
|
constructor(data) {
|
||||||
|
super(data)
|
||||||
|
this.class("c-message")
|
||||||
|
}
|
||||||
|
|
||||||
|
canGroup() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UngroupableEvent extends MatrixEvent {
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
GroupableEvent,
|
||||||
|
UngroupableEvent
|
||||||
|
}
|
18
src/js/events/hidden.js
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
const {UngroupableEvent} = require("./event")
|
||||||
|
|
||||||
|
class HiddenEvent extends UngroupableEvent {
|
||||||
|
constructor(data) {
|
||||||
|
super(data)
|
||||||
|
this.class("c-hidden-event")
|
||||||
|
this.clearChildren()
|
||||||
|
}
|
||||||
|
|
||||||
|
static canRender(eventData) {
|
||||||
|
return ["m.reaction", "m.call.candidates"].includes(eventData.type)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = [HiddenEvent]
|
48
src/js/events/image.js
Normal file
|
@ -0,0 +1,48 @@
|
||||||
|
const {ejs, ElemJS} = require("../basic")
|
||||||
|
const {resolveMxc} = require("../functions")
|
||||||
|
const {GroupableEvent} = require("./event")
|
||||||
|
|
||||||
|
class Image extends GroupableEvent {
|
||||||
|
render() {
|
||||||
|
this.clearChildren()
|
||||||
|
this.class("c-message--media")
|
||||||
|
const image = (
|
||||||
|
ejs("img")
|
||||||
|
.class("c-message__image")
|
||||||
|
.attribute("src", resolveMxc(this.data.content.url))
|
||||||
|
)
|
||||||
|
const info = this.data.content.info
|
||||||
|
if (info && info.w && info.h) {
|
||||||
|
image.attribute("width", info.w)
|
||||||
|
image.attribute("height", info.h)
|
||||||
|
}
|
||||||
|
const wrapper = ejs("div").class("c-media__wrapper").child(
|
||||||
|
image
|
||||||
|
)
|
||||||
|
if (this.data.content.body && this.data.content.body.startsWith("SPOILER")) {
|
||||||
|
wrapper.attribute("tabindex", 0)
|
||||||
|
wrapper.class("c-media--spoiler")
|
||||||
|
const wall = ejs("div").class("c-media__spoiler").text("Spoiler")
|
||||||
|
wrapper.child(wall)
|
||||||
|
const toggle = () => {
|
||||||
|
wrapper.element.classList.toggle("c-media--shown")
|
||||||
|
}
|
||||||
|
wrapper.on("click", toggle)
|
||||||
|
wrapper.on("keydown", event => {
|
||||||
|
if (event.key === "Enter") toggle()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
this.child(wrapper)
|
||||||
|
super.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
static canRender(event) {
|
||||||
|
return event.type === "m.room.message" && event.content.msgtype === "m.image"
|
||||||
|
}
|
||||||
|
|
||||||
|
canGroup() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = [Image]
|
127
src/js/events/membership.js
Normal file
|
@ -0,0 +1,127 @@
|
||||||
|
const {UngroupableEvent} = require("./event")
|
||||||
|
const {ejs} = require("../basic")
|
||||||
|
const {extractDisplayName, resolveMxc, extractLocalpart} = require("../functions")
|
||||||
|
|
||||||
|
class MembershipEvent extends UngroupableEvent {
|
||||||
|
constructor(data) {
|
||||||
|
super(data)
|
||||||
|
this.class("c-message-event")
|
||||||
|
this.senderName = extractDisplayName(data)
|
||||||
|
if (data.content.avatar_url) {
|
||||||
|
this.smallAvatar = ejs("img")
|
||||||
|
.attribute("width", "32")
|
||||||
|
.attribute("height", "32")
|
||||||
|
.attribute("src", resolveMxc(data.content.avatar_url, 32, "crop"))
|
||||||
|
.class("c-message-event__avatar")
|
||||||
|
} else {
|
||||||
|
this.smallAvatar = ""
|
||||||
|
}
|
||||||
|
this.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
static canRender(eventData) {
|
||||||
|
return eventData.type === "m.room.member"
|
||||||
|
}
|
||||||
|
|
||||||
|
renderInner(iconURL, elements) {
|
||||||
|
this.clearChildren()
|
||||||
|
this.child(
|
||||||
|
ejs("div").class("c-message-event__inner").child(
|
||||||
|
iconURL ? ejs("img").class("c-message-event__icon").attribute("width", "20").attribute("height", "20").attribute("src", iconURL) : "",
|
||||||
|
...elements
|
||||||
|
)
|
||||||
|
)
|
||||||
|
super.render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class JoinedEvent extends MembershipEvent {
|
||||||
|
static canRender(eventData) {
|
||||||
|
return super.canRender(eventData) && eventData.content.membership === "join"
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const changes = []
|
||||||
|
const prev = this.data.unsigned.prev_content
|
||||||
|
if (prev && prev.membership === "join") {
|
||||||
|
if (prev.avatar_url !== this.data.content.avatar_url) {
|
||||||
|
changes.push("changed their avatar")
|
||||||
|
}
|
||||||
|
if (prev.displayname !== this.data.content.displayname) {
|
||||||
|
changes.push(`changed their display name (was ${this.data.unsigned.prev_content.displayname})`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let message
|
||||||
|
let iconURL
|
||||||
|
if (changes.length) {
|
||||||
|
message = " " + changes.join(", ")
|
||||||
|
iconURL = "static/profile-event.svg"
|
||||||
|
} else {
|
||||||
|
message = " joined the room"
|
||||||
|
iconURL = "static/join-event.svg"
|
||||||
|
}
|
||||||
|
this.renderInner(iconURL, [
|
||||||
|
this.smallAvatar,
|
||||||
|
this.senderName,
|
||||||
|
message
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class InvitedEvent extends MembershipEvent {
|
||||||
|
static canRender(eventData) {
|
||||||
|
return super.canRender(eventData) && eventData.content.membership === "invite"
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this.renderInner("static/invite-event.svg", [
|
||||||
|
this.smallAvatar,
|
||||||
|
`${extractLocalpart(this.data.sender)} invited ${this.data.state_key}` // full mxid for clarity
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class LeaveEvent extends MembershipEvent {
|
||||||
|
static canRender(eventData) {
|
||||||
|
return super.canRender(eventData) && eventData.content.membership === "leave"
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this.renderInner("static/leave-event.svg", [
|
||||||
|
this.smallAvatar,
|
||||||
|
this.senderName,
|
||||||
|
" left the room"
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class BanEvent extends MembershipEvent {
|
||||||
|
static canRender(eventData) {
|
||||||
|
return super.canRender(eventData) && eventData.content.membership === "ban"
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
let message =
|
||||||
|
` left (banned by ${this.data.sender}`
|
||||||
|
+ (this.data.content.reason ? `, reason: ${this.data.content.reason}` : "")
|
||||||
|
+ ")"
|
||||||
|
this.renderInner("static/leave-event.svg", [
|
||||||
|
this.smallAvatar,
|
||||||
|
this.senderName,
|
||||||
|
message
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class UnknownMembership extends MembershipEvent {
|
||||||
|
render() {
|
||||||
|
this.renderInner("", [
|
||||||
|
this.smallAvatar,
|
||||||
|
this.senderName,
|
||||||
|
ejs("i").text(" unknown membership event")
|
||||||
|
])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = [JoinedEvent, InvitedEvent, LeaveEvent, BanEvent, UnknownMembership]
|
164
src/js/events/message.js
Normal file
|
@ -0,0 +1,164 @@
|
||||||
|
const {ejs, ElemJS} = require("../basic")
|
||||||
|
const {HighlightedCode} = require("./components")
|
||||||
|
const DOMPurify = require("dompurify")
|
||||||
|
const {resolveMxc} = require("../functions")
|
||||||
|
const {GroupableEvent} = require("./event")
|
||||||
|
|
||||||
|
const purifier = DOMPurify()
|
||||||
|
|
||||||
|
purifier.addHook("uponSanitizeAttribute", (node, hookevent, config) => {
|
||||||
|
// If purifier already rejected an attribute there is no point in checking it
|
||||||
|
if (hookevent.keepAttr === false) return;
|
||||||
|
|
||||||
|
const allowedElementAttributes = {
|
||||||
|
"FONT": ["data-mx-bg-color", "data-mx-color", "color"],
|
||||||
|
"SPAN": ["data-mx-bg-color", "data-mx-color", "data-mx-spoiler"],
|
||||||
|
"A": ["name", "target", "href"],
|
||||||
|
"IMG": ["width", "height", "alt", "title", "src", "data-mx-emoticon"],
|
||||||
|
"OL": ["start"],
|
||||||
|
"CODE": ["class"],
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowedAttributes = allowedElementAttributes[node.tagName] || []
|
||||||
|
hookevent.keepAttr = allowedAttributes.indexOf(hookevent.attrName) > -1
|
||||||
|
})
|
||||||
|
|
||||||
|
purifier.addHook("uponSanitizeElement", (node, hookevent, config) => {
|
||||||
|
// Remove bad classes from our code element
|
||||||
|
if (node.tagName === "CODE") {
|
||||||
|
node.classList.forEach(c => {
|
||||||
|
if (!c.startsWith("language-")) {
|
||||||
|
node.classList.remove(c)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (node.tagName === "A") {
|
||||||
|
node.setAttribute("rel", "noopener") // prevent the opening page from accessing carbon
|
||||||
|
node.setAttribute("target", "_blank") // open in a new tab instead of replacing carbon
|
||||||
|
}
|
||||||
|
return node
|
||||||
|
})
|
||||||
|
|
||||||
|
function cleanHTML(html) {
|
||||||
|
const config = {
|
||||||
|
ALLOWED_TAGS: [
|
||||||
|
"font", "del", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "p",
|
||||||
|
"a", "ul", "ol", "sup", "sub", "li", "b", "i", "u", "strong", "em",
|
||||||
|
"strike", "code", "hr", "br", "div", "table", "thead", "tbody", "tr",
|
||||||
|
"th", "td", "caption", "pre", "span", "img",
|
||||||
|
// matrix tags
|
||||||
|
"mx-reply"
|
||||||
|
],
|
||||||
|
|
||||||
|
// In case we mess up in the uponSanitizeAttribute hook
|
||||||
|
ALLOWED_ATTR: [
|
||||||
|
"color", "name", "target", "href", "width", "height", "alt", "title",
|
||||||
|
"src", "start", "class", "noreferrer", "noopener",
|
||||||
|
// matrix attrs
|
||||||
|
"data-mx-emoticon", "data-mx-bg-color", "data-mx-color", "data-mx-spoiler"
|
||||||
|
],
|
||||||
|
|
||||||
|
// Return a DOM fragment instead of a string, avoids potential future mutation XSS
|
||||||
|
// should also be faster than the browser parsing HTML twice
|
||||||
|
// https://research.securitum.com/mutation-xss-via-mathml-mutation-dompurify-2-0-17-bypass/
|
||||||
|
RETURN_DOM_FRAGMENT: true,
|
||||||
|
RETURN_DOM_IMPORT: true
|
||||||
|
}
|
||||||
|
return purifier.sanitize(html, config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Here we put all the processing of the messages that isn't as likely to potentially lead to security issues
|
||||||
|
function postProcessElements(element) {
|
||||||
|
element.querySelectorAll("pre").forEach(n => {
|
||||||
|
new HighlightedCode(n)
|
||||||
|
})
|
||||||
|
|
||||||
|
element.querySelectorAll("img").forEach(n => {
|
||||||
|
let src = n.getAttribute("src")
|
||||||
|
if (src) src = resolveMxc(src)
|
||||||
|
n.setAttribute("src", src)
|
||||||
|
})
|
||||||
|
|
||||||
|
element.querySelectorAll("font, span").forEach(n => {
|
||||||
|
const color = n.getAttribute("data-mx-color") || n.getAttribute("color")
|
||||||
|
const bgColor = n.getAttribute("data-mx-bg-color")
|
||||||
|
if (color) n.style.color = color
|
||||||
|
if (bgColor) n.style.backgroundColor = bgColor
|
||||||
|
})
|
||||||
|
|
||||||
|
element.querySelectorAll("[data-mx-spoiler]").forEach(spoiler => {
|
||||||
|
spoiler.classList.add("mx-spoiler")
|
||||||
|
spoiler.setAttribute("tabindex", 0)
|
||||||
|
function toggle() {
|
||||||
|
spoiler.classList.toggle("mx-spoiler--shown")
|
||||||
|
}
|
||||||
|
spoiler.addEventListener("click", toggle)
|
||||||
|
spoiler.addEventListener("keydown", event => {
|
||||||
|
if (event.key === "Enter") toggle()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class HTMLMessage extends GroupableEvent {
|
||||||
|
render() {
|
||||||
|
this.clearChildren()
|
||||||
|
|
||||||
|
let html = this.data.content.formatted_body
|
||||||
|
|
||||||
|
const fragment = cleanHTML(html)
|
||||||
|
postProcessElements(fragment)
|
||||||
|
|
||||||
|
this.child(ejs(fragment))
|
||||||
|
|
||||||
|
super.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
static canRender(event) {
|
||||||
|
const content = event.content
|
||||||
|
return (
|
||||||
|
event.type === "m.room.message"
|
||||||
|
&& (content.msgtype === "m.text" || content.msgtype === "m.notice")
|
||||||
|
&& content.format === "org.matrix.custom.html"
|
||||||
|
&& content.formatted_body
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function autoLinkText(text) {
|
||||||
|
const fragment = ejs(new DocumentFragment())
|
||||||
|
let lastIndex = 0
|
||||||
|
text.replace(/https?:\/\/(?:[A-Za-z-]+\.)+[A-Za-z]{1,10}(?::[0-9]{1,6})?(?:\/[^ ]*)?/g, (url, index) => {
|
||||||
|
// add text before URL
|
||||||
|
fragment.addText(text.slice(lastIndex, index))
|
||||||
|
// add URL
|
||||||
|
fragment.child(
|
||||||
|
ejs("a")
|
||||||
|
.attribute("target", "_blank")
|
||||||
|
.attribute("noopener", "")
|
||||||
|
.attribute("href", url)
|
||||||
|
.addText(url)
|
||||||
|
)
|
||||||
|
// update state
|
||||||
|
lastIndex = index + url.length
|
||||||
|
})
|
||||||
|
// add final text
|
||||||
|
fragment.addText(text.slice(lastIndex))
|
||||||
|
return fragment
|
||||||
|
}
|
||||||
|
|
||||||
|
class TextMessage extends GroupableEvent {
|
||||||
|
render() {
|
||||||
|
this.clearChildren()
|
||||||
|
this.class("c-message--plain")
|
||||||
|
const fragment = autoLinkText(this.data.content.body)
|
||||||
|
this.child(fragment)
|
||||||
|
super.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
static canRender(event) {
|
||||||
|
return event.type === "m.room.message"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = [HTMLMessage, TextMessage]
|
24
src/js/events/render-event.js
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
const imageEvent = require("./image")
|
||||||
|
const messageEvent = require("./message")
|
||||||
|
const encryptedEvent = require("./encrypted")
|
||||||
|
const membershipEvent = require("./membership")
|
||||||
|
const unknownEvent = require("./unknown")
|
||||||
|
const callEvent = require("./call")
|
||||||
|
const hiddenEvent = require("./hidden")
|
||||||
|
|
||||||
|
const events = [
|
||||||
|
...imageEvent,
|
||||||
|
...messageEvent,
|
||||||
|
...encryptedEvent,
|
||||||
|
...membershipEvent,
|
||||||
|
...callEvent,
|
||||||
|
...hiddenEvent,
|
||||||
|
...unknownEvent,
|
||||||
|
]
|
||||||
|
|
||||||
|
function renderEvent(eventData) {
|
||||||
|
const constructor = events.find(e => e.canRender(eventData))
|
||||||
|
return new constructor(eventData)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {renderEvent}
|
19
src/js/events/unknown.js
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
const {GroupableEvent} = require("./event")
|
||||||
|
const {ejs} = require("../basic")
|
||||||
|
|
||||||
|
class UnknownEvent extends GroupableEvent {
|
||||||
|
static canRender() {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this.clearChildren()
|
||||||
|
this.child(
|
||||||
|
ejs("i").text(`Unknown event of type ${this.data.type}`)
|
||||||
|
)
|
||||||
|
super.render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = [UnknownEvent]
|
||||||
|
|
11
src/js/focus.js
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
document.body.classList.remove("show-focus")
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", () => {
|
||||||
|
document.body.classList.remove("show-focus")
|
||||||
|
})
|
||||||
|
|
||||||
|
document.addEventListener("keydown", event => {
|
||||||
|
if (event.key === "Tab") {
|
||||||
|
document.body.classList.add("show-focus")
|
||||||
|
}
|
||||||
|
})
|
|
@ -1,7 +1,10 @@
|
||||||
import * as lsm from $to_relative "/js/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)
|
const match = url.match(/^mxc:\/\/([^/]+)\/(.*)/)
|
||||||
|
if (!match) return url
|
||||||
|
let [server, id] = match.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 {
|
||||||
|
@ -9,4 +12,28 @@ function resolveMxc(url, size, method) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {resolveMxc}
|
function extractLocalpart(mxid) {
|
||||||
|
// try to extract the localpart from the mxid
|
||||||
|
let match = mxid.match(/^@([^:]+):/)
|
||||||
|
if (match) {
|
||||||
|
return match[1]
|
||||||
|
}
|
||||||
|
// localpart extraction failed, use the whole mxid
|
||||||
|
return mxid
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDisplayName(stateEvent) {
|
||||||
|
const mxid = stateEvent.state_key
|
||||||
|
// see if a display name is set
|
||||||
|
if (stateEvent.content.displayname) {
|
||||||
|
return stateEvent.content.displayname
|
||||||
|
}
|
||||||
|
// fall back to the mxid
|
||||||
|
return extractLocalpart(mxid)
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
resolveMxc,
|
||||||
|
extractLocalpart,
|
||||||
|
extractDisplayName
|
||||||
|
}
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import {q} from $to_relative "/js/basic.js"
|
const {q} = require("./basic.js")
|
||||||
|
|
||||||
let state = "CLOSED"
|
let state = "CLOSED"
|
||||||
|
|
||||||
|
|
20
src/js/lazy-load-module.js
Normal file
|
@ -0,0 +1,20 @@
|
||||||
|
// I hate this with passion
|
||||||
|
async function lazyLoad(url) {
|
||||||
|
const cache = window.lazyLoadCache || new Map()
|
||||||
|
window.lazyLoadCache = cache
|
||||||
|
if (cache.get(url)) return cache.get(url)
|
||||||
|
|
||||||
|
const module = loadModuleWithoutCache(url)
|
||||||
|
cache.set(url, module)
|
||||||
|
return module
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loads the module without caching
|
||||||
|
async function loadModuleWithoutCache(url) {
|
||||||
|
const src = await fetch(url).then(r => r.text())
|
||||||
|
let module = {}
|
||||||
|
eval(src)
|
||||||
|
return module.exports
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {lazyLoad}
|
167
src/js/login.js
Normal file
|
@ -0,0 +1,167 @@
|
||||||
|
const {q, ElemJS, ejs} = require("./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, isError) {
|
||||||
|
this.removeClass("form-feedback")
|
||||||
|
this.removeClass("form-error")
|
||||||
|
if (content) this.class("form-feedback")
|
||||||
|
if(isError) this.class("form-error")
|
||||||
|
|
||||||
|
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 domain
|
||||||
|
try {
|
||||||
|
domain = await this.findHomeserver(homeserver.value)
|
||||||
|
} catch(e) {
|
||||||
|
return this.cancel(e.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request access token
|
||||||
|
this.status("Logging in...")
|
||||||
|
const root = await fetch(`${domain}/_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", domain)
|
||||||
|
localStorage.setItem("access_token", root.access_token)
|
||||||
|
|
||||||
|
location.assign("../")
|
||||||
|
}
|
||||||
|
|
||||||
|
async findHomeserver(address, maxDepth = 5) {
|
||||||
|
|
||||||
|
//Protects from servers sending us on a redirect loop
|
||||||
|
maxDepth--
|
||||||
|
if (maxDepth <= 0) throw new Error(`Failed to look up homeserver, maximum search depth reached`)
|
||||||
|
|
||||||
|
//Normalise the address
|
||||||
|
if (!address.match(/^https?:\/\//)) {
|
||||||
|
console.warn(`${address} doesn't specify the protocol, assuming https`)
|
||||||
|
address = "https://" + address
|
||||||
|
}
|
||||||
|
address = address.replace(/\/*$/, "")
|
||||||
|
|
||||||
|
this.status(`Looking up homeserver... trying ${address}`)
|
||||||
|
|
||||||
|
// Check if we found the actual matrix server
|
||||||
|
try {
|
||||||
|
const versionsReq = await fetch(`${address}/_matrix/client/versions`)
|
||||||
|
if (versionsReq.ok) {
|
||||||
|
const versions = await versionsReq.json()
|
||||||
|
if (Array.isArray(versions.versions)) return address
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
|
||||||
|
// Find the next matrix server in the chain
|
||||||
|
const root = await fetch(`${address}/.well-known/matrix/client`).then(res => res.json()).catch(e => {
|
||||||
|
console.error(e)
|
||||||
|
throw new Error(`Failed to look up server ${address}`)
|
||||||
|
})
|
||||||
|
|
||||||
|
let nextAddress = root["m.homeserver"].base_url
|
||||||
|
nextAddress = nextAddress.replace(/\/*$/, "")
|
||||||
|
|
||||||
|
if (address === nextAddress) {
|
||||||
|
throw new Error(`Failed to look up server ${address}, /.well-known/matrix/client found a redirect loop`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.findHomeserver(nextAddress, maxDepth)
|
||||||
|
}
|
||||||
|
|
||||||
|
status(message) {
|
||||||
|
feedback.setLoading(true)
|
||||||
|
feedback.message(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(message) {
|
||||||
|
this.processing = false
|
||||||
|
feedback.setLoading(false)
|
||||||
|
feedback.message(message, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = new Form()
|
|
@ -8,4 +8,4 @@ function set(name, value) {
|
||||||
|
|
||||||
window.lsm = {get, set}
|
window.lsm = {get, set}
|
||||||
|
|
||||||
export {get, set}
|
module.exports = {get, set}
|
||||||
|
|
11
src/js/main.js
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
require("./focus.js")
|
||||||
|
const groups = require("./groups.js")
|
||||||
|
const chat_input = require("./chat-input.js")
|
||||||
|
const room_picker = require("./room-picker.js")
|
||||||
|
const sync = require("./sync/sync.js")
|
||||||
|
const chat = require("./chat.js")
|
||||||
|
require("./typing.js")
|
||||||
|
|
||||||
|
if (!localStorage.getItem("access_token")) {
|
||||||
|
location.assign("./login/")
|
||||||
|
}
|
149
src/js/read-marker.js
Normal file
|
@ -0,0 +1,149 @@
|
||||||
|
const {ElemJS, ejs, q} = require("./basic.js")
|
||||||
|
const {store} = require("./store/store.js")
|
||||||
|
const lsm = require("./lsm.js")
|
||||||
|
|
||||||
|
function markFullyRead(roomID, eventID) {
|
||||||
|
return fetch(`${lsm.get("domain")}/_matrix/client/r0/rooms/${roomID}/read_markers?access_token=${lsm.get("access_token")}`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
"m.fully_read": eventID,
|
||||||
|
"m.read": eventID
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
class ReadBanner extends ElemJS {
|
||||||
|
constructor() {
|
||||||
|
super(q("#c-chat-banner"))
|
||||||
|
|
||||||
|
this.newMessages = ejs("span")
|
||||||
|
this.child(
|
||||||
|
ejs("div").class("c-chat-banner__inner").child(
|
||||||
|
ejs("button").class("c-chat-banner__part").on("click", this.jumpTo.bind(this)).child(
|
||||||
|
ejs("div").class("c-chat-banner__part-inner")
|
||||||
|
.child(this.newMessages)
|
||||||
|
.addText(" new messages")
|
||||||
|
),
|
||||||
|
ejs("button").class("c-chat-banner__part", "c-chat-banner__last").on("click", this.markRead.bind(this)).child(
|
||||||
|
ejs("div").class("c-chat-banner__part-inner").text("Mark as read")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
store.activeRoom.subscribe("changeSelf", this.render.bind(this))
|
||||||
|
store.notificationsChange.subscribe("changeSelf", this.render.bind(this))
|
||||||
|
this.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
async jumpTo() {
|
||||||
|
if (!store.activeRoom.exists()) return
|
||||||
|
const timeline = store.activeRoom.value().timeline
|
||||||
|
const readMarker = timeline.readMarker
|
||||||
|
while (true) {
|
||||||
|
if (readMarker.attached) {
|
||||||
|
readMarker.element.scrollIntoView({behavior: "smooth", block: "center"})
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
q("#c-chat-messages").scrollTo({
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
behavior: "smooth"
|
||||||
|
})
|
||||||
|
await new Promise(resolve => {
|
||||||
|
const unsubscribe = timeline.subscribe("afterScrollbackLoad", () => {
|
||||||
|
unsubscribe()
|
||||||
|
resolve()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
markRead() {
|
||||||
|
if (!store.activeRoom.exists()) return
|
||||||
|
const timeline = store.activeRoom.value().timeline
|
||||||
|
markFullyRead(timeline.id, timeline.latestEventID)
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
let count = 0
|
||||||
|
if (store.activeRoom.exists()) {
|
||||||
|
count = store.activeRoom.value().number.state.unreads
|
||||||
|
}
|
||||||
|
if (count !== 0) {
|
||||||
|
this.newMessages.text(count)
|
||||||
|
this.class("c-chat-banner--active")
|
||||||
|
} else {
|
||||||
|
this.removeClass("c-chat-banner--active")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const readBanner = new ReadBanner()
|
||||||
|
|
||||||
|
class ReadMarker extends ElemJS {
|
||||||
|
constructor(timeline) {
|
||||||
|
super("div")
|
||||||
|
|
||||||
|
this.class("c-read-marker")
|
||||||
|
this.loadingIcon = ejs("div")
|
||||||
|
.class("c-read-marker__loading", "loading-icon")
|
||||||
|
.style("display", "none")
|
||||||
|
this.child(
|
||||||
|
ejs("div").class("c-read-marker__inner").child(
|
||||||
|
ejs("div").class("c-read-marker__text").child(this.loadingIcon).addText("New")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
let processing = false
|
||||||
|
const observer = new IntersectionObserver(entries => {
|
||||||
|
const entry = entries[0]
|
||||||
|
if (!entry.isIntersecting) return
|
||||||
|
if (processing) return
|
||||||
|
processing = true
|
||||||
|
this.loadingIcon.style("display", "")
|
||||||
|
markFullyRead(this.timeline.id, this.timeline.latestEventID).then(() => {
|
||||||
|
this.loadingIcon.style("display", "none")
|
||||||
|
processing = false
|
||||||
|
})
|
||||||
|
}, {
|
||||||
|
root: document.getElementById("c-chat-messages"),
|
||||||
|
rootMargin: "-80px 0px 0px 0px", // marker must be this distance inside the top of the screen to be counted as read
|
||||||
|
threshold: 0.01
|
||||||
|
})
|
||||||
|
observer.observe(this.element)
|
||||||
|
|
||||||
|
this.attached = false
|
||||||
|
this.timeline = timeline
|
||||||
|
this.timeline.userReads.get(lsm.get("mx_user_id")).subscribe("changeSelf", (_, eventID) => {
|
||||||
|
// read marker updated, attach to it
|
||||||
|
const event = this.timeline.map.get(eventID)
|
||||||
|
this.attach(event)
|
||||||
|
})
|
||||||
|
this.timeline.subscribe("afterChange", () => {
|
||||||
|
// timeline has new events, attach to last read one
|
||||||
|
const eventID = this.timeline.userReads.get(lsm.get("mx_user_id")).value()
|
||||||
|
const event = this.timeline.map.get(eventID)
|
||||||
|
this.attach(event)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
attach(event) {
|
||||||
|
if (event && event.data.origin_server_ts !== this.timeline.latest) {
|
||||||
|
this.class("c-read-marker--attached")
|
||||||
|
event.element.insertAdjacentElement("beforeend", this.element)
|
||||||
|
this.attached = true
|
||||||
|
} else {
|
||||||
|
this.removeClass("c-read-marker--attached")
|
||||||
|
this.attached = false
|
||||||
|
}
|
||||||
|
if (store.activeRoom.value() === this.timeline.room) {
|
||||||
|
readBanner.render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
ReadMarker,
|
||||||
|
readBanner,
|
||||||
|
markFullyRead
|
||||||
|
}
|
|
@ -1,10 +1,10 @@
|
||||||
import {q, ElemJS, ejs} from $to_relative "/js/basic.js"
|
const {q, ElemJS, ejs} = require("./basic.js")
|
||||||
import {store} from $to_relative "/js/store/store.js"
|
const {store} = require("./store/store.js")
|
||||||
import {SubscribeMapList} from $to_relative "/js/store/SubscribeMapList.js"
|
const {SubscribeMapList} = require("./store/subscribe_map_list.js")
|
||||||
import {SubscribeValue} from $to_relative "/js/store/SubscribeValue.js"
|
const {SubscribeValue} = require("./store/subscribe_value.js")
|
||||||
import {Timeline} from $to_relative "/js/Timeline.js"
|
const {Timeline} = require("./timeline.js")
|
||||||
import * as lsm from $to_relative "/js/lsm.js"
|
const lsm = require("./lsm.js")
|
||||||
import {resolveMxc} from $to_relative "/js/functions.js"
|
const {resolveMxc, extractLocalpart, extractDisplayName} = require("./functions.js")
|
||||||
|
|
||||||
class ActiveGroupMarker extends ElemJS {
|
class ActiveGroupMarker extends ElemJS {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
@ -25,12 +25,43 @@ class ActiveGroupMarker extends ElemJS {
|
||||||
|
|
||||||
const activeGroupMarker = new ActiveGroupMarker()
|
const activeGroupMarker = new ActiveGroupMarker()
|
||||||
|
|
||||||
|
class GroupNotifier extends ElemJS {
|
||||||
|
constructor() {
|
||||||
|
super("div")
|
||||||
|
|
||||||
|
this.class("c-group__number")
|
||||||
|
this.state = {}
|
||||||
|
this.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
update(state) {
|
||||||
|
Object.assign(this.state, state)
|
||||||
|
this.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this.state = {}
|
||||||
|
this.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
let total = Object.values(this.state).reduce((a, c) => a + c, 0)
|
||||||
|
if (total > 0) {
|
||||||
|
this.text(total)
|
||||||
|
this.class("c-group__number--active")
|
||||||
|
} else {
|
||||||
|
this.removeClass("c-group__number--active")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class Group extends ElemJS {
|
class Group extends ElemJS {
|
||||||
constructor(key, data) {
|
constructor(key, data) {
|
||||||
super("div")
|
super("div")
|
||||||
|
|
||||||
this.data = data
|
this.data = data
|
||||||
this.order = this.data.order
|
this.order = this.data.order
|
||||||
|
this.number = new GroupNotifier()
|
||||||
|
|
||||||
this.class("c-group")
|
this.class("c-group")
|
||||||
this.child(
|
this.child(
|
||||||
|
@ -38,6 +69,7 @@ class Group extends ElemJS {
|
||||||
? ejs("img").class("c-group__icon").attribute("src", this.data.icon)
|
? ejs("img").class("c-group__icon").attribute("src", this.data.icon)
|
||||||
: ejs("div").class("c-group__icon")
|
: ejs("div").class("c-group__icon")
|
||||||
),
|
),
|
||||||
|
this.number,
|
||||||
ejs("div").class("c-group__name").text(this.data.name)
|
ejs("div").class("c-group__name").text(this.data.name)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -56,12 +88,73 @@ class Group extends ElemJS {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class RoomNotifier extends ElemJS {
|
||||||
|
constructor(room) {
|
||||||
|
super("div")
|
||||||
|
|
||||||
|
this.class("c-room__number")
|
||||||
|
|
||||||
|
this.room = room
|
||||||
|
this.classes = [
|
||||||
|
"notifications",
|
||||||
|
"unreads",
|
||||||
|
"none"
|
||||||
|
]
|
||||||
|
this.state = {
|
||||||
|
notifications: 0,
|
||||||
|
unreads: 0
|
||||||
|
}
|
||||||
|
this.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} state
|
||||||
|
* @param {number} [state.notifications]
|
||||||
|
* @param {number} [state.unreads]
|
||||||
|
*/
|
||||||
|
update(state) {
|
||||||
|
Object.assign(this.state, state)
|
||||||
|
this.informGroup()
|
||||||
|
this.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
informGroup() {
|
||||||
|
this.room.getGroup().number.update({[this.room.id]: (
|
||||||
|
this.state.notifications || (this.state.unreads ? 1 : 0)
|
||||||
|
)})
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const display = {
|
||||||
|
number: this.state.notifications || this.state.unreads,
|
||||||
|
kind: this.state.notifications ? "notifications" : "unreads"
|
||||||
|
}
|
||||||
|
// set number
|
||||||
|
if (display.number) {
|
||||||
|
this.text(display.number)
|
||||||
|
} else {
|
||||||
|
this.text("")
|
||||||
|
display.kind = "none"
|
||||||
|
}
|
||||||
|
// set class
|
||||||
|
this.classes.forEach(c => {
|
||||||
|
const name = "c-room__number--" + c
|
||||||
|
if (c === display.kind) {
|
||||||
|
this.class(name)
|
||||||
|
} else {
|
||||||
|
this.removeClass(name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class Room extends ElemJS {
|
class Room extends ElemJS {
|
||||||
constructor(id, data) {
|
constructor(id, data) {
|
||||||
super("div")
|
super("div")
|
||||||
|
|
||||||
this.id = id
|
this.id = id
|
||||||
this.data = data
|
this.data = data
|
||||||
|
this.number = new RoomNotifier(this)
|
||||||
this.timeline = new Timeline(this)
|
this.timeline = new Timeline(this)
|
||||||
this.group = null
|
this.group = null
|
||||||
this.members = new SubscribeMapList(SubscribeValue)
|
this.members = new SubscribeMapList(SubscribeValue)
|
||||||
|
@ -75,43 +168,80 @@ class Room extends ElemJS {
|
||||||
}
|
}
|
||||||
|
|
||||||
get order() {
|
get order() {
|
||||||
if (this.group) {
|
let string = ""
|
||||||
let chars = 36
|
if (this.number.state.notifications) {
|
||||||
let total = 0
|
string += "N"
|
||||||
const name = this.getName()
|
} else if (this.number.state.unreads) {
|
||||||
for (let i = 0; i < name.length; i++) {
|
string += "U"
|
||||||
const c = name[i]
|
|
||||||
let d = 0
|
|
||||||
if (c >= "A" && c <= "Z") d = c.charCodeAt(0) - 65 + 10
|
|
||||||
else if (c >= "a" && c <= "z") d = c.charCodeAt(0) - 97 + 10
|
|
||||||
else if (c >= "0" && c <= "9") d = +c
|
|
||||||
total += d * chars ** (-i)
|
|
||||||
}
|
|
||||||
return total
|
|
||||||
} else {
|
} else {
|
||||||
return -this.timeline.latest
|
string += "_"
|
||||||
|
}
|
||||||
|
if (this.group) {
|
||||||
|
string += this.name
|
||||||
|
} else {
|
||||||
|
string += (4000000000000 - this.timeline.latest) // good until 2065 :)
|
||||||
|
}
|
||||||
|
return string
|
||||||
|
}
|
||||||
|
|
||||||
|
getMemberName(mxid) {
|
||||||
|
if (this.members.has(mxid)) {
|
||||||
|
const state = this.members.get(mxid).value()
|
||||||
|
return extractDisplayName(state)
|
||||||
|
} else {
|
||||||
|
return extractLocalpart(mxid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getHeroes() {
|
||||||
|
if (this.data.summary) {
|
||||||
|
return this.data.summary["m.heroes"]
|
||||||
|
} else {
|
||||||
|
const me = lsm.get("mx_user_id")
|
||||||
|
return this.data.state.events.filter(e => e.type === "m.room.member" && e.content.membership === "join" && e.state_key !== me).map(e => e.state_key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
getName() {
|
getName() {
|
||||||
|
// if the room has a name
|
||||||
let name = this.data.state.events.find(e => e.type === "m.room.name")
|
let name = this.data.state.events.find(e => e.type === "m.room.name")
|
||||||
if (name) {
|
if (name && name.content.name) {
|
||||||
name = name.content.name
|
return name.content.name
|
||||||
} else {
|
|
||||||
const users = this.data.summary["m.heroes"]
|
|
||||||
const usernames = users.map(u => (u.match(/^@([^:]+):/) || [])[1] || u)
|
|
||||||
name = usernames.join(", ")
|
|
||||||
}
|
}
|
||||||
return name
|
// if the room has no name, use its canonical alias
|
||||||
|
let canonicalAlias = this.data.state.events.find(e => e.type === "m.room.canonical_alias")
|
||||||
|
if (canonicalAlias && canonicalAlias.content.alias) {
|
||||||
|
return canonicalAlias.content.alias
|
||||||
|
}
|
||||||
|
// if the room has no alias, use the names of its members ("heroes")
|
||||||
|
const users = this.getHeroes()
|
||||||
|
if (users && users.length) {
|
||||||
|
const usernames = users.map(mxid => this.getMemberName(mxid))
|
||||||
|
return usernames.join(", ")
|
||||||
|
}
|
||||||
|
// the room is empty
|
||||||
|
return "Empty room"
|
||||||
}
|
}
|
||||||
|
|
||||||
getIcon() {
|
getIcon() {
|
||||||
|
// if the room has a normal avatar
|
||||||
const avatar = this.data.state.events.find(e => e.type === "m.room.avatar")
|
const avatar = this.data.state.events.find(e => e.type === "m.room.avatar")
|
||||||
if (avatar) {
|
if (avatar) {
|
||||||
return resolveMxc(avatar.content.url || avatar.content.avatar_url, 32, "crop")
|
const url = avatar.content.url || avatar.content.avatar_url
|
||||||
} else {
|
if (url) {
|
||||||
return null
|
return resolveMxc(url, 32, "crop")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// if the room has no avatar set, use a member's avatar
|
||||||
|
const users = this.getHeroes()
|
||||||
|
if (users && users[0] && this.members.has(users[0])) {
|
||||||
|
// console.log(users[0], this.members.get(users[0]))
|
||||||
|
const userAvatar = this.members.get(users[0]).value().content.avatar_url
|
||||||
|
if (userAvatar) {
|
||||||
|
return resolveMxc(userAvatar, 32, "crop")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
isDirect() {
|
isDirect() {
|
||||||
|
@ -144,6 +274,7 @@ class Room extends ElemJS {
|
||||||
this.child(ejs("div").class("c-room__icon", "c-room__icon--no-icon"))
|
this.child(ejs("div").class("c-room__icon", "c-room__icon--no-icon"))
|
||||||
}
|
}
|
||||||
this.child(ejs("div").class("c-room__name").text(this.getName()))
|
this.child(ejs("div").class("c-room__name").text(this.getName()))
|
||||||
|
this.child(this.number)
|
||||||
// active
|
// active
|
||||||
const active = store.activeRoom.value() === this
|
const active = store.activeRoom.value() === this
|
||||||
this.element.classList[active ? "add" : "remove"]("c-room--active")
|
this.element.classList[active ? "add" : "remove"]("c-room--active")
|
||||||
|
@ -163,6 +294,7 @@ class Rooms extends ElemJS {
|
||||||
store.activeGroup.subscribe("changeSelf", this.render.bind(this))
|
store.activeGroup.subscribe("changeSelf", this.render.bind(this))
|
||||||
store.directs.subscribe("changeItem", this.render.bind(this))
|
store.directs.subscribe("changeItem", this.render.bind(this))
|
||||||
store.newEvents.subscribe("changeSelf", this.sort.bind(this))
|
store.newEvents.subscribe("changeSelf", this.sort.bind(this))
|
||||||
|
store.notificationsChange.subscribe("changeSelf", this.sort.bind(this))
|
||||||
|
|
||||||
this.render()
|
this.render()
|
||||||
}
|
}
|
||||||
|
@ -223,8 +355,12 @@ class Groups extends ElemJS {
|
||||||
render() {
|
render() {
|
||||||
this.clearChildren()
|
this.clearChildren()
|
||||||
store.groups.forEach((key, item) => {
|
store.groups.forEach((key, item) => {
|
||||||
|
item.value().number.clear()
|
||||||
this.child(item.value())
|
this.child(item.value())
|
||||||
})
|
})
|
||||||
|
store.rooms.forEach((id, room) => {
|
||||||
|
room.value().number.informGroup() // update group notification number
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const groups = new Groups()
|
const groups = new Groups()
|
||||||
|
|
120
src/js/sender.js
Normal 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
|
||||||
|
}
|
|
@ -1,41 +0,0 @@
|
||||||
import {Subscribable} from $to_relative "/js/store/Subscribable.js"
|
|
||||||
import {SubscribeValue} from $to_relative "/js/store/SubscribeValue.js"
|
|
||||||
|
|
||||||
class SubscribeMap extends Subscribable {
|
|
||||||
constructor() {
|
|
||||||
super()
|
|
||||||
Object.assign(this.events, {
|
|
||||||
addItem: [],
|
|
||||||
changeItem: [],
|
|
||||||
removeItem: []
|
|
||||||
})
|
|
||||||
this.map = new Map()
|
|
||||||
}
|
|
||||||
|
|
||||||
has(key) {
|
|
||||||
return this.map.has(key) && this.map.get(key).exists()
|
|
||||||
}
|
|
||||||
|
|
||||||
get(key) {
|
|
||||||
if (this.map.has(key)) {
|
|
||||||
return this.map.get(key)
|
|
||||||
} else {
|
|
||||||
this.map.set(key, new SubscribeValue())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
set(key, value) {
|
|
||||||
let s
|
|
||||||
if (this.map.has(key)) {
|
|
||||||
s = this.map.get(key).set(value)
|
|
||||||
this.broadcast("changeItem", key)
|
|
||||||
} else {
|
|
||||||
s = new SubscribeValue().set(value)
|
|
||||||
this.map.set(key, s)
|
|
||||||
this.broadcast("addItem", key)
|
|
||||||
}
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export {SubscribeMap}
|
|
|
@ -1,7 +1,7 @@
|
||||||
import {Subscribable} from $to_relative "/js/store/Subscribable.js"
|
const {Subscribable} = require("./subscribable.js")
|
||||||
import {SubscribeMapList} from $to_relative "/js/store/SubscribeMapList.js"
|
const {SubscribeMapList} = require("./subscribe_map_list.js")
|
||||||
import {SubscribeSet} from $to_relative "/js/store/SubscribeSet.js"
|
const {SubscribeSet} = require("./subscribe_set.js")
|
||||||
import {SubscribeValue} from $to_relative "/js/store/SubscribeValue.js"
|
const {SubscribeValue} = require("./subscribe_value.js")
|
||||||
|
|
||||||
const store = {
|
const store = {
|
||||||
groups: new SubscribeMapList(SubscribeValue),
|
groups: new SubscribeMapList(SubscribeValue),
|
||||||
|
@ -9,9 +9,10 @@ const store = {
|
||||||
directs: new SubscribeSet(),
|
directs: new SubscribeSet(),
|
||||||
activeGroup: new SubscribeValue(),
|
activeGroup: new SubscribeValue(),
|
||||||
activeRoom: new SubscribeValue(),
|
activeRoom: new SubscribeValue(),
|
||||||
newEvents: new Subscribable()
|
newEvents: new Subscribable(),
|
||||||
|
notificationsChange: new Subscribable()
|
||||||
}
|
}
|
||||||
|
|
||||||
window.store = store
|
window.store = store
|
||||||
|
|
||||||
export {store}
|
module.exports = {store}
|
||||||
|
|
|
@ -20,6 +20,8 @@ class Subscribable {
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Cannot subscribe to non-existent event ${event}, available events are: ${Object.keys(this.events).join(", ")}`)
|
throw new Error(`Cannot subscribe to non-existent event ${event}, available events are: ${Object.keys(this.events).join(", ")}`)
|
||||||
}
|
}
|
||||||
|
// return a function we can call to easily unsubscribe
|
||||||
|
return () => this.unsubscribe(event, callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
unsubscribe(event, callback) {
|
unsubscribe(event, callback) {
|
||||||
|
@ -35,4 +37,4 @@ class Subscribable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {Subscribable}
|
module.exports = {Subscribable}
|
74
src/js/store/subscribe_map.js
Normal file
|
@ -0,0 +1,74 @@
|
||||||
|
const {Subscribable} = require("./subscribable.js")
|
||||||
|
|
||||||
|
class SubscribeMap extends Subscribable {
|
||||||
|
constructor(inner) {
|
||||||
|
super()
|
||||||
|
this.inner = inner
|
||||||
|
Object.assign(this.events, {
|
||||||
|
addItem: [],
|
||||||
|
editItem: [],
|
||||||
|
deleteItem: [],
|
||||||
|
changeItem: [],
|
||||||
|
askSet: []
|
||||||
|
})
|
||||||
|
Object.assign(this.eventDeps, {
|
||||||
|
addItem: ["changeItem"],
|
||||||
|
editItem: ["changeItem"],
|
||||||
|
deleteItem: ["changeItem"],
|
||||||
|
changeItem: [],
|
||||||
|
askSet: []
|
||||||
|
})
|
||||||
|
this.map = new Map()
|
||||||
|
}
|
||||||
|
|
||||||
|
has(key) {
|
||||||
|
return this.map.has(key) && this.map.get(key).exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key) {
|
||||||
|
if (this.map.has(key)) {
|
||||||
|
return this.map.get(key)
|
||||||
|
} else {
|
||||||
|
const item = new this.inner()
|
||||||
|
this.map.set(key, item)
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
forEach(f) {
|
||||||
|
for (const entry of this.map.entries()) {
|
||||||
|
f(entry[0], entry[1])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
askSet(key, value) {
|
||||||
|
this.broadcast("askSet", {key, value})
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key, value) {
|
||||||
|
let s
|
||||||
|
if (this.map.has(key)) {
|
||||||
|
const exists = this.map.get(key).exists()
|
||||||
|
s = this.map.get(key).set(value)
|
||||||
|
if (exists) {
|
||||||
|
this.broadcast("editItem", key)
|
||||||
|
} else {
|
||||||
|
this.broadcast("addItem", key)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s = new this.inner().set(value)
|
||||||
|
this.map.set(key, s)
|
||||||
|
this.broadcast("addItem", key)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(key) {
|
||||||
|
if (this.backing.has(key)) {
|
||||||
|
this.backing.delete(key)
|
||||||
|
this.broadcast("deleteItem", key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {SubscribeMap}
|
|
@ -1,5 +1,5 @@
|
||||||
import {Subscribable} from $to_relative "/js/store/Subscribable.js"
|
const {Subscribable} = require("./subscribable.js")
|
||||||
import {SubscribeValue} from $to_relative "/js/store/SubscribeValue.js"
|
const {SubscribeValue} = require("./subscribe_value.js")
|
||||||
|
|
||||||
class SubscribeMapList extends Subscribable {
|
class SubscribeMapList extends Subscribable {
|
||||||
constructor(inner) {
|
constructor(inner) {
|
||||||
|
@ -54,6 +54,15 @@ class SubscribeMapList extends Subscribable {
|
||||||
}
|
}
|
||||||
|
|
||||||
sort() {
|
sort() {
|
||||||
|
const key = this.list[0]
|
||||||
|
if (typeof this.map.get(key).value().order === "number") {
|
||||||
|
this.sortByNumber()
|
||||||
|
} else {
|
||||||
|
this.sortByString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sortByNumber() {
|
||||||
this.list.sort((a, b) => {
|
this.list.sort((a, b) => {
|
||||||
const orderA = this.map.get(a).value().order
|
const orderA = this.map.get(a).value().order
|
||||||
const orderB = this.map.get(b).value().order
|
const orderB = this.map.get(b).value().order
|
||||||
|
@ -62,6 +71,17 @@ class SubscribeMapList extends Subscribable {
|
||||||
this.broadcast("changeItem")
|
this.broadcast("changeItem")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sortByString() {
|
||||||
|
this.list.sort((a, b) => {
|
||||||
|
const orderA = this.map.get(a).value().order
|
||||||
|
const orderB = this.map.get(b).value().order
|
||||||
|
if (orderA < orderB) return -1
|
||||||
|
else if (orderA > orderB) return 1
|
||||||
|
else return 0
|
||||||
|
})
|
||||||
|
this.broadcast("changeItem")
|
||||||
|
}
|
||||||
|
|
||||||
_add(key, value, start) {
|
_add(key, value, start) {
|
||||||
let s
|
let s
|
||||||
if (this.map.has(key)) {
|
if (this.map.has(key)) {
|
||||||
|
@ -83,4 +103,4 @@ class SubscribeMapList extends Subscribable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {SubscribeMapList}
|
module.exports = {SubscribeMapList}
|
|
@ -1,4 +1,4 @@
|
||||||
import {Subscribable} from $to_relative "/js/store/Subscribable.js"
|
const {Subscribable} = require("./subscribable.js")
|
||||||
|
|
||||||
class SubscribeSet extends Subscribable {
|
class SubscribeSet extends Subscribable {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
@ -47,4 +47,4 @@ class SubscribeSet extends Subscribable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {SubscribeSet}
|
module.exports = {SubscribeSet}
|
|
@ -1,4 +1,4 @@
|
||||||
import {Subscribable} from $to_relative "/js/store/Subscribable.js"
|
const {Subscribable} = require("./subscribable.js")
|
||||||
|
|
||||||
class SubscribeValue extends Subscribable {
|
class SubscribeValue extends Subscribable {
|
||||||
constructor() {
|
constructor() {
|
||||||
|
@ -30,7 +30,7 @@ class SubscribeValue extends Subscribable {
|
||||||
|
|
||||||
edit(f) {
|
edit(f) {
|
||||||
if (this.exists()) {
|
if (this.exists()) {
|
||||||
f(this.data)
|
this.data = f(this.data)
|
||||||
this.set(this.data)
|
this.set(this.data)
|
||||||
} else {
|
} else {
|
||||||
throw new Error("Tried to edit a SubscribeValue that had no value")
|
throw new Error("Tried to edit a SubscribeValue that had no value")
|
||||||
|
@ -44,4 +44,4 @@ class SubscribeValue extends Subscribable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export {SubscribeValue}
|
module.exports = {SubscribeValue}
|
|
@ -1,6 +1,6 @@
|
||||||
import {store} from $to_relative "/js/store/store.js"
|
const {store} = require("../store/store.js")
|
||||||
import * as lsm from $to_relative "/js/lsm.js"
|
const lsm = require("../lsm.js")
|
||||||
import {resolveMxc} from $to_relative "/js/functions.js"
|
const {resolveMxc} = require("../functions.js")
|
||||||
|
|
||||||
let lastBatch = null
|
let lastBatch = null
|
||||||
|
|
||||||
|
@ -11,7 +11,7 @@ function sync() {
|
||||||
room: {
|
room: {
|
||||||
// pulling more from the timeline massively increases download size
|
// pulling more from the timeline massively increases download size
|
||||||
timeline: {
|
timeline: {
|
||||||
limit: 5
|
limit: 1
|
||||||
},
|
},
|
||||||
// members are not currently needed
|
// members are not currently needed
|
||||||
state: {
|
state: {
|
||||||
|
@ -37,63 +37,88 @@ function sync() {
|
||||||
function manageSync(root) {
|
function manageSync(root) {
|
||||||
try {
|
try {
|
||||||
let newEvents = false
|
let newEvents = false
|
||||||
|
let notificationsChange = false
|
||||||
|
|
||||||
// set up directs
|
// set up directs
|
||||||
const directs = root.account_data.events.find(e => e.type === "m.direct")
|
if (root.account_data) {
|
||||||
if (directs) {
|
const directs = root.account_data.events.find(e => e.type === "m.direct")
|
||||||
Object.values(directs.content).forEach(ids => {
|
if (directs) {
|
||||||
ids.forEach(id => store.directs.add(id))
|
Object.values(directs.content).forEach(ids => {
|
||||||
})
|
ids.forEach(id => store.directs.add(id))
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// set up rooms
|
// set up rooms
|
||||||
Object.entries(root.rooms.join).forEach(([id, data]) => {
|
if (root.rooms) {
|
||||||
if (!store.rooms.has(id)) {
|
if (root.rooms.join) {
|
||||||
store.rooms.askAdd(id, data)
|
Object.entries(root.rooms.join).forEach(([id, data]) => {
|
||||||
|
if (!store.rooms.has(id)) {
|
||||||
|
store.rooms.askAdd(id, data)
|
||||||
|
}
|
||||||
|
const room = store.rooms.get(id).value()
|
||||||
|
const timeline = room.timeline
|
||||||
|
if (data.state && data.state.events) timeline.updateStateEvents(data.state.events)
|
||||||
|
if (data.timeline && data.timeline.events) {
|
||||||
|
if (!timeline.from) timeline.from = data.timeline.prev_batch
|
||||||
|
if (data.timeline.events.length) {
|
||||||
|
newEvents = true
|
||||||
|
timeline.updateEvents(data.timeline.events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (data.ephemeral && data.ephemeral.events) timeline.updateEphemeral(data.ephemeral.events)
|
||||||
|
if (data.unread_notifications) {
|
||||||
|
timeline.updateNotificationCount(data.unread_notifications.notification_count)
|
||||||
|
notificationsChange = true
|
||||||
|
}
|
||||||
|
if (data["org.matrix.msc2654.unread_count"] != undefined) {
|
||||||
|
timeline.updateUnreadCount(data["org.matrix.msc2654.unread_count"])
|
||||||
|
notificationsChange = true
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
const room = store.rooms.get(id).value()
|
}
|
||||||
const timeline = room.timeline
|
|
||||||
if (data.timeline.events.length) newEvents = true
|
|
||||||
timeline.updateStateEvents(data.state.events)
|
|
||||||
timeline.updateEvents(data.timeline.events)
|
|
||||||
})
|
|
||||||
|
|
||||||
// set up groups
|
// set up groups
|
||||||
Promise.all(
|
if (root.groups) {
|
||||||
Object.keys(root.groups.join).map(id => {
|
Promise.all(
|
||||||
if (!store.groups.has(id)) {
|
Object.keys(root.groups.join).map(id => {
|
||||||
return Promise.all(["profile", "rooms"].map(path => {
|
if (!store.groups.has(id)) {
|
||||||
const url = new URL(`${lsm.get("domain")}/_matrix/client/r0/groups/${id}/${path}`)
|
return Promise.all(["profile", "rooms"].map(path => {
|
||||||
url.searchParams.append("access_token", lsm.get("access_token"))
|
const url = new URL(`${lsm.get("domain")}/_matrix/client/r0/groups/${id}/${path}`)
|
||||||
return fetch(url.toString()).then(res => res.json())
|
url.searchParams.append("access_token", lsm.get("access_token"))
|
||||||
})).then(([profile, rooms]) => {
|
return fetch(url.toString()).then(res => res.json())
|
||||||
rooms = rooms.chunk
|
})).then(([profile, rooms]) => {
|
||||||
let order = 999
|
rooms = rooms.chunk
|
||||||
let orderEvent = root.account_data.events.find(e => e.type === "im.vector.web.tag_ordering")
|
let order = 999
|
||||||
if (orderEvent) {
|
let orderEvent = root.account_data.events.find(e => e.type === "im.vector.web.tag_ordering")
|
||||||
if (orderEvent.content.tags.includes(id)) {
|
if (orderEvent) {
|
||||||
order = orderEvent.content.tags.indexOf(id)
|
if (orderEvent.content.tags.includes(id)) {
|
||||||
|
order = orderEvent.content.tags.indexOf(id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
const data = {
|
||||||
const data = {
|
name: profile.name,
|
||||||
name: profile.name,
|
icon: resolveMxc(profile.avatar_url, 96, "crop"),
|
||||||
icon: resolveMxc(profile.avatar_url, 96, "crop"),
|
order
|
||||||
order
|
|
||||||
}
|
|
||||||
store.groups.askAdd(id, data)
|
|
||||||
rooms.forEach(groupRoom => {
|
|
||||||
if (store.rooms.has(groupRoom.room_id)) {
|
|
||||||
store.rooms.get(groupRoom.room_id).value().setGroup(id)
|
|
||||||
}
|
}
|
||||||
|
store.groups.askAdd(id, data)
|
||||||
|
rooms.forEach(groupRoom => {
|
||||||
|
if (store.rooms.has(groupRoom.room_id)) {
|
||||||
|
store.rooms.get(groupRoom.room_id).value().setGroup(id)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
store.newEvents.broadcast("changeSelf") // trigger a room list update
|
||||||
})
|
})
|
||||||
store.newEvents.broadcast("changeSelf") // trigger a room list update
|
}
|
||||||
})
|
})
|
||||||
}
|
).then(() => {
|
||||||
|
store.rooms.sort()
|
||||||
})
|
})
|
||||||
).then(() => {
|
}
|
||||||
store.rooms.sort()
|
|
||||||
})
|
|
||||||
if (newEvents) store.newEvents.broadcast("changeSelf")
|
if (newEvents) store.newEvents.broadcast("changeSelf")
|
||||||
|
if (notificationsChange) store.notificationsChange.broadcast("changeSelf")
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(root)
|
console.error(root)
|
||||||
throw e
|
throw e
|
||||||
|
@ -121,4 +146,6 @@ function syncLoop() {
|
||||||
|
|
||||||
store.activeGroup.set(store.groups.get("directs").value())
|
store.activeGroup.set(store.groups.get("directs").value())
|
||||||
|
|
||||||
syncLoop()
|
if (lsm.get("access_token")) {
|
||||||
|
syncLoop()
|
||||||
|
}
|
||||||
|
|
408
src/js/timeline.js
Normal file
|
@ -0,0 +1,408 @@
|
||||||
|
const {ElemJS, ejs, q} = require("./basic.js")
|
||||||
|
const {Subscribable} = require("./store/subscribable.js")
|
||||||
|
const {SubscribeValue} = require("./store/subscribe_value.js")
|
||||||
|
const {SubscribeMap} = require("./store/subscribe_map.js")
|
||||||
|
const {store} = require("./store/store.js")
|
||||||
|
const {Anchor} = require("./anchor.js")
|
||||||
|
const {Sender} = require("./sender.js")
|
||||||
|
const {ReadMarker, markFullyRead} = require("./read-marker.js")
|
||||||
|
const lsm = require("./lsm.js")
|
||||||
|
const {resolveMxc} = require("./functions.js")
|
||||||
|
const {renderEvent} = require("./events/render-event")
|
||||||
|
const {dateFormatter} = require("./date-formatter")
|
||||||
|
|
||||||
|
let debug = false
|
||||||
|
|
||||||
|
const NO_MAX = Symbol("NO_MAX")
|
||||||
|
|
||||||
|
let sentIndex = 0
|
||||||
|
|
||||||
|
function getTxnId() {
|
||||||
|
return Date.now() + (sentIndex++)
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventSearch(list, event, min = 0, max = NO_MAX) {
|
||||||
|
if (list.length === 0) return {success: false, i: 0}
|
||||||
|
|
||||||
|
if (max === NO_MAX) max = list.length - 1
|
||||||
|
let mid = Math.floor((max + min) / 2)
|
||||||
|
// success condition
|
||||||
|
if (list[mid] && list[mid].data.event_id === event.data.event_id) return {success: true, i: mid}
|
||||||
|
// failed condition
|
||||||
|
if (min >= max) {
|
||||||
|
while (mid !== -1 && (!list[mid] || list[mid].data.origin_server_ts > event.data.origin_server_ts)) mid--
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
i: mid + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// recurse (below)
|
||||||
|
if (list[mid].data.origin_server_ts > event.data.origin_server_ts) return eventSearch(list, event, min, mid - 1)
|
||||||
|
// recurse (above)
|
||||||
|
else return eventSearch(list, event, mid + 1, max)
|
||||||
|
}
|
||||||
|
|
||||||
|
class EventGroup extends ElemJS {
|
||||||
|
constructor(reactive, list) {
|
||||||
|
super("div")
|
||||||
|
this.class("c-message-group")
|
||||||
|
this.reactive = reactive
|
||||||
|
this.list = list
|
||||||
|
this.data = {
|
||||||
|
sender: list[0].data.sender,
|
||||||
|
origin_server_ts: list[0].data.origin_server_ts
|
||||||
|
}
|
||||||
|
this.sender = new Sender(this.reactive.id, this.data.sender)
|
||||||
|
this.child(
|
||||||
|
this.sender.avatar,
|
||||||
|
this.messages = ejs("div").class("c-message-group__messages").child(
|
||||||
|
ejs("div").class("c-message-group__intro").child(
|
||||||
|
this.sender.name,
|
||||||
|
ejs("div").class("c-message-group__date").text(dateFormatter.format(this.data.origin_server_ts))
|
||||||
|
),
|
||||||
|
...this.list
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
canGroup() {
|
||||||
|
if (this.list.length) return this.list[0].canGroup()
|
||||||
|
else return true
|
||||||
|
}
|
||||||
|
|
||||||
|
addEvent(event) {
|
||||||
|
const index = eventSearch(this.list, event).i
|
||||||
|
event.setGroup(this)
|
||||||
|
this.list.splice(index, 0, event)
|
||||||
|
this.messages.childAt(index + 1, event)
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEvent(event) {
|
||||||
|
const search = eventSearch(this.list, event)
|
||||||
|
if (!search.success) throw new Error(`Event ${event.data.event_id} not found in this group`)
|
||||||
|
const index = search.i
|
||||||
|
// actually remove the event
|
||||||
|
this.list.splice(index, 1)
|
||||||
|
event.remove() // should get everything else
|
||||||
|
if (this.list.length === 0) this.reactive.removeGroup(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 {
|
||||||
|
constructor(id, list) {
|
||||||
|
super("div")
|
||||||
|
this.class("c-event-groups")
|
||||||
|
this.id = id
|
||||||
|
this.list = list
|
||||||
|
this.loadMore = new LoadMore(this.id)
|
||||||
|
this.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
addEvent(event) {
|
||||||
|
this.loadMore.remove()
|
||||||
|
// if (debug) console.log("running search", this.list, event)
|
||||||
|
// if (debug) debugger;
|
||||||
|
const search = eventSearch(this.list, event)
|
||||||
|
// console.log(search, this.list.map(l => l.data.sender), event.data)
|
||||||
|
if (!search.success) {
|
||||||
|
if (search.i >= 1) {
|
||||||
|
// add at end
|
||||||
|
this.tryAddGroups(event, [search.i - 1, search.i])
|
||||||
|
} else {
|
||||||
|
// add at start
|
||||||
|
this.tryAddGroups(event, [0, -1])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.tryAddGroups(event, [search.i])
|
||||||
|
}
|
||||||
|
this.loadMore = new LoadMore(this.id)
|
||||||
|
this.childAt(0, this.loadMore)
|
||||||
|
}
|
||||||
|
|
||||||
|
tryAddGroups(event, indices) {
|
||||||
|
const createGroupAt = i => {
|
||||||
|
// if (printed++ < 100) console.log("tryadd success, created group")
|
||||||
|
if (i === -1) {
|
||||||
|
// here, -1 means at the start, before the first group
|
||||||
|
i = 0 // jank but it does the trick
|
||||||
|
}
|
||||||
|
if (event.canGroup()) {
|
||||||
|
const group = new EventGroup(this, [event])
|
||||||
|
this.list.splice(i, 0, group)
|
||||||
|
this.childAt(i, group)
|
||||||
|
event.setGroup(group)
|
||||||
|
} else {
|
||||||
|
this.list.splice(i, 0, event)
|
||||||
|
this.childAt(i, event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const success = indices.some(i => {
|
||||||
|
if (!this.list[i]) {
|
||||||
|
createGroupAt(i)
|
||||||
|
return true
|
||||||
|
} else if (event.canGroup() && this.list[i] && this.list[i].canGroup() && this.list[i].data.sender === event.data.sender) {
|
||||||
|
// if (printed++ < 100) console.log("tryadd success, using existing group")
|
||||||
|
this.list[i].addEvent(event)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// if (!success) console.log("tryadd failure", indices, this.list.map(l => l.data.sender), event.data) // I believe all the bugs are now fixed. Lol.
|
||||||
|
if (!success) createGroupAt(indices[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
removeGroup(group) {
|
||||||
|
const index = this.list.indexOf(group)
|
||||||
|
this.list.splice(index, 1)
|
||||||
|
group.remove() // should get everything else
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
this.clearChildren()
|
||||||
|
this.child(this.loadMore)
|
||||||
|
this.list.forEach(group => this.child(group))
|
||||||
|
this.anchor = new Anchor()
|
||||||
|
this.child(this.anchor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Timeline extends Subscribable {
|
||||||
|
constructor(room) {
|
||||||
|
super()
|
||||||
|
Object.assign(this.events, {
|
||||||
|
beforeChange: [],
|
||||||
|
afterChange: [],
|
||||||
|
beforeScrollbackLoad: [],
|
||||||
|
afterScrollbackLoad: [],
|
||||||
|
})
|
||||||
|
Object.assign(this.eventDeps, {
|
||||||
|
beforeChange: [],
|
||||||
|
afterChange: [],
|
||||||
|
beforeScrollbackLoad: [],
|
||||||
|
afterScrollbackLoad: [],
|
||||||
|
})
|
||||||
|
this.room = room
|
||||||
|
this.id = this.room.id
|
||||||
|
this.list = []
|
||||||
|
this.map = new Map()
|
||||||
|
this.reactiveTimeline = new ReactiveTimeline(this.id, [])
|
||||||
|
this.latest = 0
|
||||||
|
this.latestEventID = null
|
||||||
|
this.pending = new Set()
|
||||||
|
this.pendingEdits = []
|
||||||
|
this.typing = new SubscribeValue().set([])
|
||||||
|
this.userReads = new SubscribeMap(SubscribeValue)
|
||||||
|
this.readMarker = new ReadMarker(this)
|
||||||
|
this.from = null
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStateEvents(events) {
|
||||||
|
for (const eventData of events) {
|
||||||
|
let id = eventData.event_id
|
||||||
|
if (eventData.type === "m.room.member") {
|
||||||
|
// update members
|
||||||
|
if (eventData.membership !== "leave") {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEvents(events) {
|
||||||
|
this.broadcast("beforeChange")
|
||||||
|
// handle state events
|
||||||
|
this.updateStateEvents(events)
|
||||||
|
for (const eventData of events) {
|
||||||
|
// set variables
|
||||||
|
let id = eventData.event_id
|
||||||
|
if (eventData.origin_server_ts > this.latest) {
|
||||||
|
this.latest = eventData.origin_server_ts
|
||||||
|
this.latestEventID = id
|
||||||
|
}
|
||||||
|
// handle local echoes
|
||||||
|
if (eventData.sender === lsm.get("mx_user_id") && eventData.content && this.pending.has(eventData.content["chat.carbon.message.pending_id"])) {
|
||||||
|
const pendingID = eventData.content["chat.carbon.message.pending_id"]
|
||||||
|
if (id !== pendingID) {
|
||||||
|
const target = this.map.get(pendingID)
|
||||||
|
this.map.set(id, target)
|
||||||
|
this.map.delete(pendingID)
|
||||||
|
// update fully read marker - assume we have fully read up to messages we send
|
||||||
|
markFullyRead(this.id, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// handle timeline events
|
||||||
|
if (this.map.has(id)) {
|
||||||
|
// update existing event
|
||||||
|
this.map.get(id).update(eventData)
|
||||||
|
} else {
|
||||||
|
// skip redacted events
|
||||||
|
if (eventData.unsigned && eventData.unsigned.redacted_by) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// handle redactions
|
||||||
|
if (eventData.type === "m.room.redaction") {
|
||||||
|
if (this.map.has(eventData.redacts)) this.map.get(eventData.redacts).removeEvent()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// handle edits
|
||||||
|
if (eventData.type === "m.room.message" && eventData.content["m.relates_to"] && eventData.content["m.relates_to"].rel_type === "m.replace") {
|
||||||
|
this.pendingEdits.push(eventData)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// add new event
|
||||||
|
const event = renderEvent(eventData)
|
||||||
|
this.map.set(id, event)
|
||||||
|
this.reactiveTimeline.addEvent(event)
|
||||||
|
// update read receipt for sender on their own event
|
||||||
|
this.moveReadReceipt(eventData.sender, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// apply edits
|
||||||
|
this.pendingEdits = this.pendingEdits.filter(eventData => {
|
||||||
|
const replaces = eventData.content["m.relates_to"].event_id
|
||||||
|
if (this.map.has(replaces)) {
|
||||||
|
const event = this.map.get(replaces)
|
||||||
|
event.data.content = eventData.content["m.new_content"]
|
||||||
|
event.setEdited(eventData.origin_server_ts)
|
||||||
|
event.update(event.data)
|
||||||
|
return false // handled; remove from list
|
||||||
|
} else {
|
||||||
|
return true // we don't have the event it edits yet; keep in list
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.broadcast("afterChange")
|
||||||
|
}
|
||||||
|
|
||||||
|
updateEphemeral(events) {
|
||||||
|
for (const eventData of events) {
|
||||||
|
if (eventData.type === "m.typing") {
|
||||||
|
this.typing.set(eventData.content.user_ids)
|
||||||
|
}
|
||||||
|
if (eventData.type === "m.receipt") {
|
||||||
|
for (const eventID of Object.keys(eventData.content)) {
|
||||||
|
for (const user of Object.keys(eventData.content[eventID]["m.read"])) {
|
||||||
|
this.moveReadReceipt(user, eventID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// console.log("Updated read receipts:", this.userReads)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
moveReadReceipt(user, eventID) {
|
||||||
|
// check for a previous event to move from
|
||||||
|
const prev = this.userReads.get(user)
|
||||||
|
if (prev.exists()) {
|
||||||
|
const prevID = prev.value()
|
||||||
|
if (this.map.has(prevID) && this.map.has(eventID)) {
|
||||||
|
// ensure new message came later
|
||||||
|
if (this.map.get(eventID).data.origin_server_ts < this.map.get(prevID).data.origin_server_ts) return
|
||||||
|
this.map.get(prevID).readBy.delete(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// set on new message
|
||||||
|
this.userReads.set(user, eventID)
|
||||||
|
if (this.map.has(eventID)) this.map.get(eventID).readBy.add(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
updateUnreadCount(count) {
|
||||||
|
this.room.number.update({unreads: count})
|
||||||
|
}
|
||||||
|
|
||||||
|
updateNotificationCount(count) {
|
||||||
|
this.room.number.update({notifications: count})
|
||||||
|
}
|
||||||
|
|
||||||
|
removeEvent(id) {
|
||||||
|
if (!this.map.has(id)) throw new Error(`Tried to delete event ID ${id} which does not exist`)
|
||||||
|
this.map.get(id).removeEvent()
|
||||||
|
this.map.delete(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
getTimeline() {
|
||||||
|
return this.reactiveTimeline
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadScrollback() {
|
||||||
|
debug = true
|
||||||
|
if (!this.from) return // no more scrollback for this timeline
|
||||||
|
const url = new URL(`${lsm.get("domain")}/_matrix/client/r0/rooms/${this.id}/messages`)
|
||||||
|
url.searchParams.set("access_token", lsm.get("access_token"))
|
||||||
|
url.searchParams.set("from", this.from)
|
||||||
|
url.searchParams.set("dir", "b")
|
||||||
|
url.searchParams.set("limit", "20")
|
||||||
|
const filter = {
|
||||||
|
lazy_load_members: true
|
||||||
|
}
|
||||||
|
url.searchParams.set("filter", JSON.stringify(filter))
|
||||||
|
|
||||||
|
const root = await fetch(url.toString()).then(res => res.json())
|
||||||
|
|
||||||
|
this.broadcast("beforeScrollbackLoad")
|
||||||
|
|
||||||
|
this.from = root.end
|
||||||
|
// console.log(this.updateEvents, root.chunk)
|
||||||
|
if (root.state) this.updateStateEvents(root.state)
|
||||||
|
if (root.chunk.length) {
|
||||||
|
// there are events to display
|
||||||
|
this.updateEvents(root.chunk)
|
||||||
|
}
|
||||||
|
if (!root.chunk.length || !root.end) {
|
||||||
|
// we reached the top of the scrollback
|
||||||
|
this.reactiveTimeline.loadMore.remove()
|
||||||
|
}
|
||||||
|
this.broadcast("afterScrollbackLoad")
|
||||||
|
}
|
||||||
|
|
||||||
|
send(type, content) {
|
||||||
|
const tx = getTxnId()
|
||||||
|
const id = `pending$${tx}`
|
||||||
|
this.pending.add(id)
|
||||||
|
content["chat.carbon.message.pending_id"] = id
|
||||||
|
const fakeEvent = {
|
||||||
|
type,
|
||||||
|
origin_server_ts: Date.now(),
|
||||||
|
event_id: id,
|
||||||
|
sender: lsm.get("mx_user_id"),
|
||||||
|
content,
|
||||||
|
pending: true
|
||||||
|
}
|
||||||
|
this.updateEvents([fakeEvent])
|
||||||
|
return fetch(`${lsm.get("domain")}/_matrix/client/r0/rooms/${this.id}/send/m.room.message/${tx}?access_token=${lsm.get("access_token")}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(content),
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {Timeline}
|
69
src/js/typing.js
Normal file
|
@ -0,0 +1,69 @@
|
||||||
|
const {ElemJS, ejs, q} = require("./basic")
|
||||||
|
const {store} = require("./store/store")
|
||||||
|
const lsm = require("./lsm")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum number of typing users to display all names for.
|
||||||
|
* More will be shown as "X users are typing".
|
||||||
|
*/
|
||||||
|
const maxUsers = 4
|
||||||
|
|
||||||
|
function getMemberName(mxid) {
|
||||||
|
return store.activeRoom.value().getMemberName(mxid)
|
||||||
|
}
|
||||||
|
|
||||||
|
class Typing extends ElemJS {
|
||||||
|
constructor() {
|
||||||
|
super(q("#c-typing"))
|
||||||
|
|
||||||
|
this.typingUnsubscribe = null
|
||||||
|
|
||||||
|
this.message = ejs("span")
|
||||||
|
this.child(this.message)
|
||||||
|
|
||||||
|
store.activeRoom.subscribe("changeSelf", this.changeRoom.bind(this))
|
||||||
|
}
|
||||||
|
|
||||||
|
changeRoom() {
|
||||||
|
if (this.typingUnsubscribe) {
|
||||||
|
this.typingUnsubscribe()
|
||||||
|
this.typingUnsubscribe = null
|
||||||
|
}
|
||||||
|
if (!store.activeRoom.exists()) return
|
||||||
|
const room = store.activeRoom.value()
|
||||||
|
this.typingUnsubscribe = room.timeline.typing.subscribe("changeSelf", this.render.bind(this))
|
||||||
|
this.render()
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (!store.activeRoom.exists()) return
|
||||||
|
const room = store.activeRoom.value()
|
||||||
|
let users = [...room.timeline.typing.value()]
|
||||||
|
// don't show own typing status
|
||||||
|
users = users.filter(u => u !== lsm.get("mx_user_id"))
|
||||||
|
if (users.length === 0) {
|
||||||
|
// nobody is typing
|
||||||
|
this.removeClass("c-typing--typing")
|
||||||
|
} else {
|
||||||
|
let message = ""
|
||||||
|
if (users.length === 1) {
|
||||||
|
message = `${getMemberName(users[0])} is typing...`
|
||||||
|
} else if (users.length <= maxUsers) {
|
||||||
|
// feel free to rewrite this loop if you know a better way
|
||||||
|
for (let i = 0; i < users.length; i++) {
|
||||||
|
if (i < users.length-1) {
|
||||||
|
message += `${getMemberName(users[i])}, `
|
||||||
|
} else {
|
||||||
|
message += `and ${getMemberName(users[i])} are typing...`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
message = `${users.length} people are typing...`
|
||||||
|
}
|
||||||
|
this.class("c-typing--typing")
|
||||||
|
this.message.text(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
new Typing()
|
|
@ -1,21 +1,32 @@
|
||||||
doctype html
|
doctype html
|
||||||
html
|
html
|
||||||
head
|
head
|
||||||
meta(charset="utf-8")
|
meta(charset="utf-8")
|
||||||
link(rel="stylesheet" type="text/css" href=getStatic("/sass/main.sass"))
|
title Carbon
|
||||||
title Carbon
|
meta(name="viewport" content="width=device-width, initial-scale=1")
|
||||||
body
|
link(rel="stylesheet" type="text/css" href=getStatic("/sass/login.sass"))
|
||||||
main.main
|
script(type="module" src=getStatic("/js/login.js"))
|
||||||
form
|
|
||||||
div
|
body
|
||||||
label(for="login") Username
|
main.main
|
||||||
input(type="text" name="login" autocomplete="username" placeholder="example:matrix.org" required)#login
|
.center-login-container
|
||||||
div
|
h1 Welcome to Carbon!
|
||||||
label(for="password") Password
|
form.login-form(method="post" onsubmit="return false")#form
|
||||||
input(type="text" name="password" autocomplete="current-password" required)#password
|
.data-input
|
||||||
div
|
.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")
|
|
||||||
|
|
|
@ -21,3 +21,39 @@ body
|
||||||
.main
|
.main
|
||||||
height: 100vh
|
height: 100vh
|
||||||
display: flex
|
display: flex
|
||||||
|
|
||||||
|
button
|
||||||
|
appearance: none
|
||||||
|
border: none
|
||||||
|
background: none
|
||||||
|
color: inherit
|
||||||
|
font-family: inherit
|
||||||
|
font-size: inherit
|
||||||
|
font-style: inherit
|
||||||
|
font-weight: inherit
|
||||||
|
padding: 0
|
||||||
|
margin: 0
|
||||||
|
line-height: inherit
|
||||||
|
cursor: inherit
|
||||||
|
|
||||||
|
// focus resets
|
||||||
|
|
||||||
|
:focus
|
||||||
|
outline: none
|
||||||
|
|
||||||
|
:-moz-focusring
|
||||||
|
outline: none
|
||||||
|
|
||||||
|
::-moz-focus-inner
|
||||||
|
border: 0
|
||||||
|
|
||||||
|
select:-moz-focusring
|
||||||
|
color: transparent
|
||||||
|
text-shadow: 0 0 0 #ddd
|
||||||
|
|
||||||
|
body.show-focus
|
||||||
|
a, select, button, input, video, div, span
|
||||||
|
outline-color: #fff
|
||||||
|
|
||||||
|
&:focus
|
||||||
|
outline: 2px dotted
|
||||||
|
|
|
@ -5,3 +5,5 @@ $mild: #393c42
|
||||||
$milder: #42454a
|
$milder: #42454a
|
||||||
$divider: #4b4e54
|
$divider: #4b4e54
|
||||||
$muted: #999
|
$muted: #999
|
||||||
|
$link: #57bffd
|
||||||
|
$notify-highlight: #ffac4b
|
||||||
|
|
50
src/sass/components/chat-banner.sass
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
@use "../colors" as c
|
||||||
|
|
||||||
|
.c-chat-banner
|
||||||
|
position: sticky
|
||||||
|
z-index: 1
|
||||||
|
top: 0
|
||||||
|
left: 0
|
||||||
|
right: 0
|
||||||
|
margin-right: 12px
|
||||||
|
outline-color: #000
|
||||||
|
opacity: 0
|
||||||
|
transform: translateY(-40px)
|
||||||
|
transition: transform 0.2s ease, opacity 0.2s ease-out
|
||||||
|
|
||||||
|
&--active
|
||||||
|
opacity: 1
|
||||||
|
transform: translateY(0px)
|
||||||
|
|
||||||
|
&__inner
|
||||||
|
display: grid
|
||||||
|
grid-template-columns: 1fr auto
|
||||||
|
background: c.$notify-highlight
|
||||||
|
color: #000
|
||||||
|
margin: 0px 12px
|
||||||
|
padding: 0px 12px
|
||||||
|
border-radius: 0px 0px 10px 10px
|
||||||
|
line-height: 1
|
||||||
|
box-shadow: 0px 5px 5px -2px rgba(0, 0, 0, 0.1)
|
||||||
|
cursor: pointer
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
box-shadow: 0px 5px 5px -2px rgba(0, 0, 0, 0.6)
|
||||||
|
|
||||||
|
&__part
|
||||||
|
padding: 6px 0px 8px
|
||||||
|
|
||||||
|
&:hover
|
||||||
|
text-decoration: underline
|
||||||
|
|
||||||
|
&__part-inner
|
||||||
|
display: block
|
||||||
|
width: 100% // yes, really.
|
||||||
|
text-align: left
|
||||||
|
|
||||||
|
&__last
|
||||||
|
margin-left: 8px
|
||||||
|
|
||||||
|
&__last &__part-inner
|
||||||
|
border-left: 1px solid #222
|
||||||
|
padding-left: 8px
|
|
@ -6,11 +6,14 @@
|
||||||
-webkit-appearance: $value
|
-webkit-appearance: $value
|
||||||
|
|
||||||
.c-chat-input
|
.c-chat-input
|
||||||
|
position: relative
|
||||||
width: 100%
|
width: 100%
|
||||||
border-top: 2px solid c.$divider
|
border-top: 2px solid c.$divider
|
||||||
background-color: c.$dark
|
background-color: c.$dark
|
||||||
|
|
||||||
&__textarea
|
&__textarea
|
||||||
|
position: relative
|
||||||
|
z-index: 1
|
||||||
width: calc(100% - 40px)
|
width: calc(100% - 40px)
|
||||||
height: 16px + (16px * 1.45)
|
height: 16px + (16px * 1.45)
|
||||||
box-sizing: border-box
|
box-sizing: border-box
|
||||||
|
|
|
@ -2,11 +2,12 @@
|
||||||
|
|
||||||
.c-chat
|
.c-chat
|
||||||
display: grid
|
display: grid
|
||||||
grid-template-rows: 1fr 82px // fixed so that input box height adjustment doesn't mess up scroll
|
grid-template-rows: 0 1fr 82px // fixed so that input box height adjustment doesn't mess up scroll
|
||||||
align-items: end
|
align-items: end
|
||||||
flex: 1
|
flex: 1
|
||||||
|
|
||||||
&__messages
|
&__messages
|
||||||
|
position: relative
|
||||||
height: 100%
|
height: 100%
|
||||||
overflow-y: scroll
|
overflow-y: scroll
|
||||||
scrollbar-color: c.$darkest c.$darker
|
scrollbar-color: c.$darkest c.$darker
|
||||||
|
|
|
@ -36,11 +36,13 @@ $out-width: $base-width + rooms.$list-width
|
||||||
box-sizing: border-box
|
box-sizing: border-box
|
||||||
|
|
||||||
.c-group
|
.c-group
|
||||||
|
position: relative
|
||||||
display: flex
|
display: flex
|
||||||
align-items: center
|
align-items: center
|
||||||
padding: $icon-padding / 2 $icon-padding
|
padding: $icon-padding / 2 $icon-padding
|
||||||
cursor: pointer
|
cursor: pointer
|
||||||
border-radius: 8px
|
border-radius: 8px
|
||||||
|
background-color: c.$darkest
|
||||||
|
|
||||||
&:hover
|
&:hover
|
||||||
background-color: c.$darker
|
background-color: c.$darker
|
||||||
|
@ -62,6 +64,29 @@ $out-width: $base-width + rooms.$list-width
|
||||||
overflow: hidden
|
overflow: hidden
|
||||||
text-overflow: ellipsis
|
text-overflow: ellipsis
|
||||||
|
|
||||||
|
&__number
|
||||||
|
position: absolute
|
||||||
|
right: 240px
|
||||||
|
bottom: 0px
|
||||||
|
background: #ddd
|
||||||
|
color: #000
|
||||||
|
font-size: 14px
|
||||||
|
line-height: 1
|
||||||
|
padding: 3px 4px
|
||||||
|
border-radius: 7px
|
||||||
|
border: 3px solid c.$darkest
|
||||||
|
opacity: 0
|
||||||
|
transform: translate(6px, 6px)
|
||||||
|
transition: transform 0.15s ease-out, opacity 0.15s ease-out
|
||||||
|
pointer-events: none
|
||||||
|
|
||||||
|
@at-root .c-group:hover &
|
||||||
|
border-color: c.$darker
|
||||||
|
|
||||||
|
&--active
|
||||||
|
opacity: 1
|
||||||
|
transform: translate(0px, 0px)
|
||||||
|
|
||||||
.c-group-marker
|
.c-group-marker
|
||||||
position: absolute
|
position: absolute
|
||||||
top: 5px
|
top: 5px
|
||||||
|
|
1
src/sass/components/highlighted-code.sass
Normal file
|
@ -0,0 +1 @@
|
||||||
|
@use "../../../node_modules/highlight.js/scss/obsidian"
|
|
@ -1,6 +1,6 @@
|
||||||
@use "../colors" as c
|
@use "../colors" as c
|
||||||
|
|
||||||
.c-event-groups *
|
.c-event-groups > *
|
||||||
overflow-anchor: none
|
overflow-anchor: none
|
||||||
|
|
||||||
.c-message-group, .c-message-event
|
.c-message-group, .c-message-event
|
||||||
|
@ -9,7 +9,8 @@
|
||||||
border-top: 1px solid c.$divider
|
border-top: 1px solid c.$divider
|
||||||
|
|
||||||
.c-message-group
|
.c-message-group
|
||||||
display: flex
|
display: grid
|
||||||
|
grid-template-columns: auto 1fr
|
||||||
|
|
||||||
&__avatar
|
&__avatar
|
||||||
flex-shrink: 0
|
flex-shrink: 0
|
||||||
|
@ -23,7 +24,7 @@
|
||||||
border-radius: 50%
|
border-radius: 50%
|
||||||
|
|
||||||
&--no-icon
|
&--no-icon
|
||||||
background-color: #48d
|
background-color: #bbb
|
||||||
|
|
||||||
&__intro
|
&__intro
|
||||||
display: flex
|
display: flex
|
||||||
|
@ -46,9 +47,19 @@
|
||||||
|
|
||||||
.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
|
||||||
|
|
||||||
|
&--plain
|
||||||
|
white-space: pre-wrap
|
||||||
|
|
||||||
|
&--media
|
||||||
|
// fix whitespace
|
||||||
|
font-size: 0
|
||||||
|
margin-top: 8px
|
||||||
|
display: flex
|
||||||
|
|
||||||
&--pending
|
&--pending
|
||||||
opacity: 0.5
|
opacity: 0.5
|
||||||
|
|
||||||
|
@ -66,18 +77,70 @@
|
||||||
&:hover
|
&:hover
|
||||||
background-color: c.$darker
|
background-color: c.$darker
|
||||||
|
|
||||||
|
&__image
|
||||||
|
width: auto
|
||||||
|
height: auto
|
||||||
|
max-width: 400px
|
||||||
|
max-height: 300px
|
||||||
|
|
||||||
|
// message formatting rules
|
||||||
|
|
||||||
|
code, pre
|
||||||
|
border-radius: 4px
|
||||||
|
font-size: 0.9em
|
||||||
|
|
||||||
|
pre
|
||||||
|
background-color: c.$darkest
|
||||||
|
padding: 8px
|
||||||
|
border: 1px solid c.$divider
|
||||||
|
white-space: pre-wrap
|
||||||
|
|
||||||
|
code
|
||||||
|
background-color: c.$darker
|
||||||
|
padding: 2px 4px
|
||||||
|
|
||||||
|
a
|
||||||
|
color: c.$link
|
||||||
|
|
||||||
|
blockquote
|
||||||
|
margin-left: 8px
|
||||||
|
border-left: 4px solid c.$muted
|
||||||
|
padding: 2px 0px 2px 12px
|
||||||
|
|
||||||
|
p, pre, blockquote
|
||||||
|
margin: 16px 0px
|
||||||
|
|
||||||
|
&:first-child
|
||||||
|
margin-top: 0px
|
||||||
|
|
||||||
|
&:last-child
|
||||||
|
margin-bottom: 0px
|
||||||
|
|
||||||
.c-message-event
|
.c-message-event
|
||||||
padding-top: 10px
|
// closer spacing than normal messages
|
||||||
|
padding-top: 2px
|
||||||
padding-left: 6px
|
padding-left: 6px
|
||||||
|
margin-bottom: -4px
|
||||||
|
line-height: 1.2
|
||||||
|
|
||||||
&__inner
|
&__inner
|
||||||
display: flex
|
text-indent: -36px
|
||||||
align-items: center
|
margin-left: 36px
|
||||||
|
|
||||||
|
img
|
||||||
|
// let me know if there's a smarter way to line this shit up
|
||||||
|
position: relative
|
||||||
|
top: -5px
|
||||||
|
transform: translateY(50%)
|
||||||
|
|
||||||
&__icon
|
&__icon
|
||||||
margin-right: 8px
|
margin-right: 8px
|
||||||
position: relative
|
|
||||||
top: 1px
|
&__avatar
|
||||||
|
width: 16px
|
||||||
|
height: 16px
|
||||||
|
border-radius: 50%
|
||||||
|
margin: 0px 6px
|
||||||
|
|
||||||
.c-message-notice
|
.c-message-notice
|
||||||
padding: 12px
|
padding: 12px
|
||||||
|
@ -87,3 +150,37 @@
|
||||||
padding: 12px
|
padding: 12px
|
||||||
background-color: c.$milder
|
background-color: c.$milder
|
||||||
border-radius: 8px
|
border-radius: 8px
|
||||||
|
|
||||||
|
.c-media
|
||||||
|
&__wrapper
|
||||||
|
overflow: hidden
|
||||||
|
position: relative
|
||||||
|
|
||||||
|
&--spoiler
|
||||||
|
cursor: pointer
|
||||||
|
|
||||||
|
img
|
||||||
|
filter: blur(40px)
|
||||||
|
|
||||||
|
&--shown img
|
||||||
|
filter: none
|
||||||
|
|
||||||
|
&__spoiler
|
||||||
|
position: absolute
|
||||||
|
top: 0
|
||||||
|
bottom: 0
|
||||||
|
left: 0
|
||||||
|
right: 0
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
justify-content: center
|
||||||
|
font-size: 18px
|
||||||
|
font-weight: 500
|
||||||
|
color: #fff
|
||||||
|
text-transform: uppercase
|
||||||
|
background: rgba(0, 0, 0, 0.3)
|
||||||
|
cursor: pointer
|
||||||
|
pointer-events: none
|
||||||
|
|
||||||
|
&--shown &__spoiler
|
||||||
|
display: none
|
||||||
|
|
42
src/sass/components/read-marker.sass
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
@use "../colors" as c
|
||||||
|
|
||||||
|
.c-read-marker
|
||||||
|
display: none
|
||||||
|
position: relative
|
||||||
|
|
||||||
|
&--attached
|
||||||
|
display: block
|
||||||
|
|
||||||
|
&__inner
|
||||||
|
position: absolute
|
||||||
|
left: -64px
|
||||||
|
right: 0px
|
||||||
|
height: 2px
|
||||||
|
top: 0px
|
||||||
|
background-color: c.$notify-highlight
|
||||||
|
|
||||||
|
@at-root .c-message:last-child &
|
||||||
|
top: 11px
|
||||||
|
|
||||||
|
@at-root .c-message-event &
|
||||||
|
top: 7px
|
||||||
|
|
||||||
|
&__text
|
||||||
|
position: absolute
|
||||||
|
right: -14px
|
||||||
|
top: -9px
|
||||||
|
display: flex
|
||||||
|
align-items: center
|
||||||
|
background-color: c.$notify-highlight
|
||||||
|
color: #000
|
||||||
|
font-size: 12px
|
||||||
|
font-weight: 600
|
||||||
|
line-height: 1
|
||||||
|
padding: 4px
|
||||||
|
border-radius: 5px
|
||||||
|
text-transform: uppercase
|
||||||
|
|
||||||
|
&__loading
|
||||||
|
background-color: #000
|
||||||
|
width: 10px
|
||||||
|
height: 10px
|
|
@ -43,3 +43,23 @@ $icon-padding: 8px
|
||||||
white-space: nowrap
|
white-space: nowrap
|
||||||
overflow: hidden
|
overflow: hidden
|
||||||
text-overflow: ellipsis
|
text-overflow: ellipsis
|
||||||
|
flex: 1
|
||||||
|
|
||||||
|
&__number
|
||||||
|
flex-shrink: 0
|
||||||
|
line-height: 1
|
||||||
|
padding: 4px 5px
|
||||||
|
border-radius: 5px
|
||||||
|
font-size: 14px
|
||||||
|
pointer-events: none
|
||||||
|
|
||||||
|
&--none
|
||||||
|
display: none
|
||||||
|
|
||||||
|
&--unreads
|
||||||
|
background-color: #ddd
|
||||||
|
color: #111
|
||||||
|
|
||||||
|
&--notifications
|
||||||
|
background-color: #ffac4b
|
||||||
|
color: #000
|
||||||
|
|
8
src/sass/components/spoilers.sass
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
.mx-spoiler
|
||||||
|
color: #331911
|
||||||
|
background-color: #331911
|
||||||
|
outline-color: #fff !important
|
||||||
|
cursor: pointer
|
||||||
|
|
||||||
|
&--shown
|
||||||
|
color: inherit
|
21
src/sass/components/typing.sass
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
@use "../colors" as c
|
||||||
|
|
||||||
|
.c-typing
|
||||||
|
height: 39px
|
||||||
|
background: c.$divider
|
||||||
|
position: absolute
|
||||||
|
right: 0
|
||||||
|
left: 0
|
||||||
|
top: 0
|
||||||
|
z-index: 0
|
||||||
|
margin: 20px
|
||||||
|
border-radius: 8px
|
||||||
|
padding: 0px 12px
|
||||||
|
font-size: 14px
|
||||||
|
line-height: 19px
|
||||||
|
transform: translateY(0px)
|
||||||
|
transition: transform 0.15s ease
|
||||||
|
color: #fff
|
||||||
|
|
||||||
|
&--typing
|
||||||
|
transform: translateY(-21px)
|
13
src/sass/loading.sass
Normal 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
|
73
src/sass/login.sass
Normal file
|
@ -0,0 +1,73 @@
|
||||||
|
@use "./base"
|
||||||
|
@use "./loading.sass"
|
||||||
|
@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
|
||||||
|
|
||||||
|
.form-error
|
||||||
|
color: red
|
||||||
|
|
||||||
|
|
||||||
|
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
|
|
@ -1,7 +1,13 @@
|
||||||
@use "./base"
|
@use "./base"
|
||||||
|
@use "./loading"
|
||||||
@use "./components/groups"
|
@use "./components/groups"
|
||||||
@use "./components/rooms"
|
@use "./components/rooms"
|
||||||
@use "./components/messages"
|
@use "./components/messages"
|
||||||
@use "./components/chat"
|
@use "./components/chat"
|
||||||
@use "./components/chat-input"
|
@use "./components/chat-input"
|
||||||
|
@use "./components/typing"
|
||||||
@use "./components/anchor"
|
@use "./components/anchor"
|
||||||
|
@use "./components/highlighted-code"
|
||||||
|
@use "./components/read-marker"
|
||||||
|
@use "./components/chat-banner"
|
||||||
|
@use "./components/spoilers"
|
||||||
|
|