Compare commits

..

107 Commits

Author SHA1 Message Date
Cadence Ember 734506a300
Abandoned 2021-03-18 00:52:56 +13:00
Cadence Ember f802031848
Sync with Dendrite
continuous-integration/drone/push Build is passing Details
2020-12-15 01:05:12 +13:00
Cadence Ember 879c09f70b
Support read markers on invisible events
continuous-integration/drone/push Build is passing Details
2020-12-06 23:45:33 +13:00
Cadence Ember 9dce348a4c
Fix loading desynced messages
continuous-integration/drone/push Build is passing Details
For example, in the construct room.
2020-11-30 22:40:44 +13:00
Cadence Ember ea6ccc08ee
Render call events in timeline
continuous-integration/drone/push Build is failing Details
2020-11-29 19:47:19 +13:00
Cadence Ember 2e91ff8ff2
Recognise image spoilers (based on "body")
continuous-integration/drone/push Build is passing Details
2020-11-28 18:45:08 +13:00
Cadence Ember a004e84adc
Add spoilers 2020-11-28 17:17:50 +13:00
Cadence Ember b4dfefbac9
Render ban events 2020-11-28 17:07:54 +13:00
Cadence Ember e6fc1de276
Greatly improved membership event display
continuous-integration/drone/push Build is passing Details
2020-11-27 02:48:30 +13:00
Cadence Ember 70cae25aa7
Update readme
continuous-integration/drone/push Build is passing Details
2020-11-26 17:57:05 +13:00
Cadence Ember 6e209bafd6
Add extremely janky unread messages banner
continuous-integration/drone/push Build is passing Details
2020-11-26 16:38:12 +13:00
Cadence Ember bc861125d8
Revert. Don't use minified discord-markdown.
continuous-integration/drone/push Build is passing Details
It doesn't seem like it exposes any exports for use with `require`.
2020-11-26 14:01:05 +13:00
Cadence Ember 6297350418
Use minified discord-markdown
continuous-integration/drone/push Build is passing Details
2020-11-26 01:55:49 +13:00
Bad 69a9e2ed2f 💚 Fix drone build due to missing git
continuous-integration/drone/push Build is passing Details
2020-11-25 08:18:46 +01:00
Cadence Ember b7905bc3be
Read marker lines in chat, badges on groups
continuous-integration/drone/push Build is failing Details
Also fixed stopping typing after sending a message.
2020-11-25 19:54:09 +13:00
Cadence Ember babd098d18
Remove console.log
continuous-integration/drone/push Build is failing Details
2020-11-25 01:56:49 +13:00
Cadence Ember 229e6903fd
Display unread/notification counters on rooms
continuous-integration/drone/push Build is failing Details
2020-11-25 01:28:04 +13:00
Cadence Ember e90a2c7da8
Rename property to message namespace 2020-11-24 20:00:45 +13:00
Cadence Ember 03c7501bf1
Formatting for sent messages
continuous-integration/drone/push Build is failing Details
2020-11-24 18:58:27 +13:00
Cadence Ember 0960ca7e97
Code highlighting fixes:
continuous-integration/drone/push Build is passing Details
- Fix pre+code element moving
- Do not highlight if pre is already formatted
2020-11-14 17:27:13 +13:00
Cadence Ember c0c7278279
Support Construct homeserver
continuous-integration/drone/push Build is passing Details
2020-11-12 17:29:46 +13:00
Cadence Ember 9f6c955b63
Update progress in readme
continuous-integration/drone/push Build is passing Details
2020-11-09 01:12:09 +13:00
Cadence Ember f4b13dbde4
Send own typing status 2020-11-09 01:11:28 +13:00
Cadence Ember c87b6dcaa7
Display typing notifications
continuous-integration/drone/push Build is passing Details
2020-11-09 00:19:56 +13:00
Cadence Ember eb573fc17c
Update readme feature list 2020-11-08 01:20:58 +13:00
Cadence Ember f188d66645
Fix fullwidth layout
continuous-integration/drone/push Build is passing Details
2020-11-08 01:11:31 +13:00
Cadence Ember 4acd806e66
Render image messages
continuous-integration/drone/push Build is passing Details
2020-11-08 01:04:42 +13:00
Cadence Ember 327290e971
Autolink URLs in messages
continuous-integration/drone/push Build is passing Details
2020-11-08 00:49:12 +13:00
Cadence Ember d6be694d3b
Style blockquotes
continuous-integration/drone/push Build is passing Details
2020-11-08 00:11:32 +13:00
cadence f6b95b2ebd Merge pull request 'Rich message rendering' (#24) from rich-messages into princess
continuous-integration/drone/push Build is passing Details
Reviewed-on: cadence/Carbon#24
2020-11-07 10:46:47 +00:00
Cadence Ember 951a46d8ec
Add back highlight.js for SCSS imports
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-11-07 23:45:20 +13:00
bad 6583c192ce Merge branch 'princess' into rich-messages
continuous-integration/drone/pr Build is failing Details
continuous-integration/drone/push Build is failing Details
2020-11-05 17:05:09 +00:00
Cadence Ember 34af1be7d1
Use dependencies instead of devDependencies
continuous-integration/drone/push Build is failing Details
continuous-integration/drone/pr Build is failing Details
2020-11-05 18:14:17 +13:00
Cadence Ember 1fa7da9ebb
Use JSDelivr CDN for highlight.js
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
- downside: is somebody else's CDN
- upside: changes hljs download size from >1MB to 33k

Feel free to debate this.
2020-11-05 18:03:25 +13:00
Cadence Ember b74f0cc0dd
Don't highlight very short code blocks 2020-11-05 17:57:27 +13:00
Cadence Ember 1aebc2c100
Only hint modules once
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-11-05 17:48:13 +13:00
Cadence Ember 017f30be65
Also format m.notice
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-11-05 17:39:21 +13:00
Cadence Ember a7165fe633
Fix purify and highlight
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
- purify: apply target=_blank to links
- purify: remove ALLOWED_URI_REGEXP - this breaks external links in
  anchor elements
- purify: return a DOM fragment instead of a string
- postprocess: only highlight pre
- postprocess: remove nested code inside pre
- better style messages with css
2020-11-05 17:37:00 +13:00
Cadence Ember 8ba9d73b33
Small refactors
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
- "event" -> "eventData"
- create renderText method
- italics
2020-11-05 16:44:22 +13:00
Cadence Ember 9cf0952d3a
Change files to kebab-case
continuous-integration/drone/push Build is failing Details
continuous-integration/drone/pr Build is failing Details
2020-11-05 16:34:10 +13:00
Cadence Ember 714147b980
Fix lazy loading cache
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-11-05 16:32:42 +13:00
Cadence Ember ebf6e7ea78
Show proper user data in room list
continuous-integration/drone/push Build is passing Details
2020-11-05 16:23:40 +13:00
Bad 1bf1712684 Fix dynamic import with relative paths
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-10-31 18:24:05 +01:00
Bad 0738ce4cb1 Rename dateFormatter.js to date-formatter.js
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-10-31 18:21:04 +01:00
Bad 20e94f05e7 Lazy load highlight.js
This significantly reduces the bundle size(over 1MiB!) but it also uses
some hacks to dynamically load browserify modules on runtime(see
lazy-load-modules.js
2020-10-31 18:17:34 +01:00
Bad 4d59b1a9ac Merge branch 'princess' into rich-messages
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-10-30 23:06:34 +01:00
Bad 5a41a2c943 Push build artifacts to dev.carbon.chat
continuous-integration/drone/push Build is passing Details
2020-10-30 23:00:49 +01:00
Bad 20bacce068 Remove the simple event shorthand
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-10-29 11:31:08 +01:00
Bad f80bf36991 Style fixes 2020-10-29 11:09:15 +01:00
Bad 217a815750 Merge branch 'princess' into rich-messages
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-10-29 11:02:51 +01:00
Bad bd9623578f Add hljs and improve sanitization
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is failing Details
2020-10-29 10:42:17 +01:00
Bad c144d75c99 Remove debug console.logs 2020-10-29 10:38:12 +01:00
Bad 66ecf44048 Remove console.log from membership 2020-10-29 10:36:38 +01:00
Cadence Ember ff196a64bb
Improve message sender rendering
continuous-integration/drone/push Build is passing Details
- Refactor sender class into parts
- Sender name colour depends on mxid, like Element
  - (colours slightly modified for contrast)
- Display blank avatar if loading fails
- Remove # parts from mxc
- Don't replace member state if loaded state is older
2020-10-29 17:31:25 +13:00
Cadence Ember a4c7f29ec9
Emacs files to gitignore 2020-10-29 17:27:38 +13:00
Cadence Ember 5bfe98bdf4
Stop scrollback at top of timeline 2020-10-29 17:26:34 +13:00
Bad e08b895694 Create a simple event shorthand
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-10-26 23:16:47 +01:00
Bad d983385e16 Fix compiler warnings 2020-10-26 22:58:38 +01:00
Bad f46f9abe6e Improve rich text rendering to more closely match the recommendations from the spec 2020-10-26 22:55:54 +01:00
Bad 1a8427925c Add unknown memberships 2020-10-26 22:55:27 +01:00
a 098ea88f5d Rebase rich-messages on princess
continuous-integration/drone/push Build is passing Details
2020-10-26 21:09:36 +01:00
BadAtNames 72b42e7b26 Initial work on rich messages 2020-10-26 21:04:08 +01:00
Cadence Ember f4b368ea3e
Better message line breaking
continuous-integration/drone/push Build is passing Details
2020-10-26 23:57:00 +13:00
cadence 6da9f41519 Merge pull request 'Add scrollback' (#22) from scrollback into princess
continuous-integration/drone/push Build is passing Details
Reviewed-on: cadence/Carbon#22
2020-10-26 10:36:43 +00:00
Cadence Ember df47c8a88a
Style load more; fix message group order
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-10-26 23:35:33 +13:00
cadence 5ab182e615 Merge pull request 'Bundle with browserify' (#19) from browserify into princess
continuous-integration/drone/push Build is passing Details
Reviewed-on: cadence/Carbon#19
2020-10-26 09:33:51 +00:00
Cadence Ember 08a0990bc8
Add back jshint; format
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-10-26 22:30:57 +13:00
BadAtNames 0348fed18d Initial work on rich messages
continuous-integration/drone/push Build is passing Details
2020-10-26 09:10:02 +01:00
BadAtNames c9dffc9d4a Wait for events to load before saving scroll position
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-10-24 23:01:48 +02:00
BadAtNames 6227f6fa84 Add scrollback
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-10-24 20:56:03 +02:00
Jonathan 51905ab3f2 add browserify
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-10-23 16:15:14 +02:00
cadence d90be7a0d4 Merge pull request 'Add better .gitignore' (#17) from fix/gitignore into princess
continuous-integration/drone/push Build is passing Details
I hate you all and I don't want to waste more time pointlessly discussing this
2020-10-23 11:46:05 +00:00
bad 79aa423ebb Merge branch 'princess' into fix/gitignore
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-10-23 11:05:54 +00:00
bad 91ac7a6b3e Merge pull request 'Refactor homeserver lookup code' (#12) from refactor-homeserver-lookup into princess
continuous-integration/drone/push Build is passing Details
Reviewed-on: cadence/Carbon#12
2020-10-23 08:57:01 +00:00
Bad aa12cd68e6 Clean up code
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-10-23 10:53:27 +02:00
bad 4382928a93 Merge branch 'princess' into refactor-homeserver-lookup
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-10-23 08:48:37 +00:00
Cadence Ember 2f5955b043
Be able to load past room state (no UI yet)
continuous-integration/drone/push Build is passing Details
2020-10-22 23:03:26 +13:00
Cadence Ember 36f204624f
Refuse to send empty messages
continuous-integration/drone/push Build is passing Details
2020-10-22 23:02:50 +13:00
Jonathan 6499cd4ff6 add better .gitignore
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-10-22 11:46:46 +02:00
Cadence Ember e18c8c77ae
Fallback to room name "empty room"
continuous-integration/drone/push Build is passing Details
2020-10-22 20:56:27 +13:00
Bad 16de7edd19 Style fixes
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-10-22 09:14:58 +02:00
Bad 0113024be6 Switch to using .catch(()=>{}) instead of try catch
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-10-22 07:50:50 +02:00
Bad 1b97351ca0 Switch to using .catch(()=>{}) instead of try catch 2020-10-22 07:46:32 +02:00
Cadence Ember dce4fa6303
Merge branch 'princess' into refactor-homeserver-lookup
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
We need the redirection fix from princess in here to accurately test.
2020-10-22 17:41:16 +13:00
Cadence Ember 2ff43ea801
Style cleanup
continuous-integration/drone/pr Build is passing Details
continuous-integration/drone/push Build is passing Details
2020-10-22 17:35:48 +13:00
Cadence Ember 9e71336c5b
Update initial login redirect
continuous-integration/drone/push Build is passing Details
2020-10-22 17:30:11 +13:00
Bad 61cc4a19f3 Small cosmetic fixes
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2020-10-21 21:19:17 +02:00
Bad 0c3c06bc0a oops
continuous-integration/drone/push Build is passing Details
2020-10-21 21:16:45 +02:00
Bad 6f67ddbce5 Refactor homeserver lookup code
continuous-integration/drone/push Build was killed Details
2020-10-21 21:11:45 +02:00
Bad f1b75f5e10 Fix login redirecting to an incorrect address
continuous-integration/drone/push Build is passing Details
2020-10-21 20:03:34 +02:00
Bad c8351e8f0c Warn when assuming https
continuous-integration/drone/push Build is passing Details
2020-10-21 19:43:21 +02:00
Bad cf2c691585 Make font feedback red on error 2020-10-21 19:41:19 +02:00
Cadence Ember ff427d0354
Update readme - add bug report instructions
continuous-integration/drone/push Build is passing Details
2020-10-21 22:53:47 +13:00
Cadence Ember a56c42311f
Use sh shell instead of fish for watch script
continuous-integration/drone/push Build is passing Details
2020-10-21 22:21:21 +13:00
Cadence Ember 64c3e18788
Don't break on rooms with a redacted name
continuous-integration/drone/push Build is passing Details
2020-10-21 22:10:58 +13:00
Cadence Ember a7ded5fae3
Merge branch 'login' into princess
continuous-integration/drone/push Build is passing Details
2020-10-21 22:07:16 +13:00
Cadence Ember 4869a31ec2
Update README, remove login GUI from todo
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2020-10-21 21:57:38 +13:00
Cadence Ember 253ccbadc2
Only enter sync loop if logged in
continuous-integration/drone/push Build is passing Details
continuous-integration/drone/pr Build is passing Details
2020-10-21 20:56:36 +13:00
cadence b83cede3ec Merge pull request 'Update readme' (#7) from erkinalp/carbon:princess into princess
continuous-integration/drone/push Build is passing Details
Reviewed-on: cadence/Carbon#7
2020-10-21 07:46:10 +00:00
Cadence Ember b9df147db5
Branding
continuous-integration/drone/push Build is passing Details
2020-10-21 20:40:24 +13:00
Cadence Ember 184c876fb9
Redirect to login page if not logged in
continuous-integration/drone/push Build is passing Details
2020-10-21 20:34:09 +13:00
Cadence Ember 988dd1050b
Add note about encrypted messages
continuous-integration/drone/push Build is passing Details
2020-10-21 20:27:22 +13:00
Cadence Ember 735ca360c8
Login form feedback
continuous-integration/drone/push Build is passing Details
2020-10-21 20:23:51 +13:00
Cadence Ember 265d774b4f
Some login functionality 2020-10-21 19:33:36 +13:00
Cadence Ember 3fc8104bdd
Format Bad's code
continuous-integration/drone/push Build is passing Details
2020-10-21 18:23:44 +13:00
Cadence Ember c21ab3b90f
Adjust group list internal sizing 2020-10-21 18:18:55 +13:00
Bad 3e28d4b6e1 Style the login form
continuous-integration/drone/push Build is passing Details
2020-10-20 20:22:13 +02:00
68 changed files with 5325 additions and 1035 deletions

View File

@ -6,16 +6,40 @@ steps:
- name: build
image: node:current-alpine3.12
commands:
- apk update
- apk add git
- npm install -D
- npm run rebuild
- name: package
image: fuww/alpine-zip
when:
event:
- push
commands:
- 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
- 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
image: tianon/backblaze-b2:2
@ -28,8 +52,9 @@ steps:
from_secret: b2_account_id
KEY:
from_secret: b2_application_key
COMMIT: ${DRONE_COMMIT_SHA:0:8}
commands:
- source environment
- b2 authorize-account $ACCOUNT $KEY
- b2 upload-file $BUCKET $FILENAME $FILENAME
- echo Build artifacts avaliable at `b2 make-friendly-url $BUCKET $FILENAME`
- b2 upload-file $BUCKET $NAME.zip $NAME.zip
- echo Build artifacts avaliable at `b2 make-friendly-url $BUCKET $NAME.zip` and at https://dev.carbon.chat/$COMMIT

301
.gitignore vendored
View File

@ -1,10 +1,297 @@
# Generated files
node_modules
build
# File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig
# Editor artifacts
.vscode
# Created by https://www.toptal.com/developers/gitignore/api/node,vscode,webstorm,webstorm+all
# 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/

View File

@ -2,12 +2,35 @@
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
Carbon's planned features, compared to Discord and Guilded:
- 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
- Uses the open Matrix and Mumble systems
- 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
implemented:
- Login GUI
- Unreads
- Chat history
- Formatting
- Emojis
- Reactions
- Encryption
- Groups v2
- Group management
- Pinned channels
- Mumble integration
For more information, see [issue
#10.](https://gitdab.com/cadence/Carbon/issues/10)
## 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 run rebuild
### Hosting
### Hosting a build
Send the files from the `build` folder to a static file server. Apply
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
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.)

View File

@ -1,6 +1,6 @@
const pug = require("pug")
const sass = require("sass")
const fs = require("fs").promises
const fs = require("fs")
const os = require("os")
const crypto = require("crypto")
const path = require("path")
@ -9,6 +9,8 @@ const babel = require("@babel/core")
const fetch = require("node-fetch")
const chalk = require("chalk")
const hint = require("jshint").JSHINT
const browserify = require("browserify")
const {Transform} = require("stream")
process.chdir(pj(__dirname, "src"))
@ -16,10 +18,10 @@ const buildDir = "../build"
const validationQueue = []
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 sources = new Map()
const pugLocals = {static, links}
const pugLocals = {static: staticFiles, links}
const spec = require("./spec.js")
@ -94,6 +96,7 @@ function runHint(filename, source) {
globals: ["console", "URLSearchParams", "staticFiles"],
browser: true,
asi: true,
node: true
})
const result = hint.data()
let problems = 0
@ -125,26 +128,54 @@ function runHint(filename, source) {
}
async function addFile(sourcePath, targetPath) {
const contents = await fs.readFile(pj(".", sourcePath), {encoding: null})
static.set(sourcePath, `${targetPath}?static=${hash(contents)}`)
fs.writeFile(pj(buildDir, targetPath), contents)
const contents = await fs.promises.readFile(pj(".", sourcePath), {encoding: null});
staticFiles.set(sourcePath, `${targetPath}?static=${hash(contents)}`)
await fs.promises.writeFile(pj(buildDir, targetPath), contents)
}
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)
static.set(sourcePath, `${targetPath}?static=${hash(content)}`)
staticFiles.set(sourcePath, `${targetPath}?static=${hash(content)}`)
}
async function addJS(sourcePath, targetPath) {
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)
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) {
@ -158,7 +189,7 @@ async function addSass(sourcePath, targetPath) {
if (!(name instanceof sass.types.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") {
return new sass.types.String(result)
} else {
@ -166,10 +197,10 @@ async function addSass(sourcePath, targetPath) {
}
}
}
}).css
static.set(sourcePath, `${targetPath}?static=${hash(renderedCSS)}`)
}).css;
staticFiles.set(sourcePath, `${targetPath}?static=${hash(renderedCSS)}`)
validate(sourcePath, renderedCSS, "css")
await fs.writeFile(pj(buildDir, targetPath), renderedCSS)
await fs.promises.writeFile(pj(buildDir, targetPath), renderedCSS)
}
async function addPug(sourcePath, targetPath) {
@ -177,10 +208,10 @@ async function addPug(sourcePath, targetPath) {
return getRelative(targetPath, staticTarget)
}
function getStatic(target) {
return getRelativeHere(static.get(target))
return getRelativeHere(staticFiles.get(target))
}
function getStaticName(target) {
return getRelativeHere(static.get(target)).replace(/\?.*$/, "")
return getRelativeHere(staticFiles.get(target)).replace(/\?.*$/, "")
}
function getLink(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})
let renderedWithoutPHP = renderedHTML.replace(/<\?(?:php|=).*?\?>/gsm, "")
validate(sourcePath, renderedWithoutPHP, "html")
await fs.writeFile(pj(buildDir, targetPath), renderedHTML)
await fs.promises.writeFile(pj(buildDir, targetPath), renderedHTML)
}
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, {
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([
fs.writeFile(pj(buildDir, targetPath), originalCode),
fs.writeFile(pj(buildDir, minFilename), compiled.code),
fs.writeFile(pj(buildDir, mapFilename), JSON.stringify(compiled.map))
fs.promises.writeFile(pj(buildDir, targetPath), originalCode),
fs.promises.writeFile(pj(buildDir, minFilename), compiled.code),
fs.promises.writeFile(pj(buildDir, mapFilename), JSON.stringify(compiled.map))
])
}
@ -241,7 +272,7 @@ async function addBabel(sourcePath, targetPath) {
// Stage 3: Create dirs
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
for (const item of spec) {
@ -255,6 +286,11 @@ async function addBabel(sourcePath, targetPath) {
await addBabel(item.source, item.target)
} else if (item.type === "pug") {
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 {
throw new Error("Unknown item type: "+item.type)
}

2036
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -5,21 +5,25 @@
"main": "build.js",
"scripts": {
"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"
},
"keywords": [],
"author": "",
"license": "AGPL-3.0-only",
"dependencies": {},
"devDependencies": {
"dependencies": {
"@babel/core": "^7.11.1",
"@babel/preset-env": "^7.11.0",
"browserify": "^17.0.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",
"jshint": "^2.12.0",
"node-fetch": "^2.6.0",
"pug": "^3.0.0",
"sass": "^1.26.10"
}
},
"devDependencies": {}
}

139
spec.js
View File

@ -2,121 +2,96 @@ module.exports = [
{
type: "file",
source: "/assets/fonts/whitney-500.woff",
target: "/static/whitney-500.woff"
target: "/static/whitney-500.woff",
},
{
type: "file",
source: "/assets/fonts/whitney-400.woff",
target: "/static/whitney-400.woff"
target: "/static/whitney-400.woff",
},
{
type: "js",
source: "/js/basic.js",
target: "/static/basic.js"
type: "bundle",
source: "/js/login.js",
target: "/static/login.js",
},
{
type: "js",
source: "/js/groups.js",
target: "/static/groups.js"
},
{
type: "js",
source: "/js/chat-input.js",
target: "/static/chat-input.js"
},
{
type: "js",
source: "/js/room-picker.js",
target: "/static/room-picker.js"
},
{
type: "js",
source: "/js/store/store.js",
target: "/static/store/store.js"
},
{
type: "js",
source: "/js/store/Subscribable.js",
target: "/static/store/Subscribable.js"
},
{
type: "js",
source: "/js/store/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: "bundle",
source: "/js/main.js",
target: "/static/bundle.js"
},
{
type: "file",
source: "/assets/fonts/whitney-500.woff",
target: "/static/whitney-500.woff"
target: "/static/whitney-500.woff",
},
{
type: "file",
source: "/assets/icons/directs.svg",
target: "/static/directs.svg"
target: "/static/directs.svg",
},
{
type: "file",
source: "/assets/icons/channels.svg",
target: "/static/channels.svg"
target: "/static/channels.svg",
},
{
type: "file",
source: "/assets/icons/join-event.svg",
target: "/static/join-event.svg"
target: "/static/join-event.svg",
},
{
type: "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",
source: "/sass/main.sass",
target: "/static/main.css"
target: "/static/main.css",
},
{
type: "sass",
source: "/sass/login.sass",
target: "/static/login.css",
},
{
type: "pug",
source: "/home.pug",
target: "/index.html"
target: "/index.html",
},
{
type: "pug",
source: "/login.pug",
target: "/login.html"
}
]
target: "/login/index.html",
},
];

View File

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

View 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

View 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

View 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

View 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

View File

@ -25,9 +25,9 @@
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1"
inkscape:cx="15.649008"
inkscape:cy="8.3751893"
inkscape:zoom="11.313708"
inkscape:cx="-4.2728481"
inkscape:cy="-2.1951295"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="true"

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

View 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

View 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

View File

@ -33,19 +33,15 @@ doctype html
html
head
meta(charset="utf-8")
title Carbon
// var static = !{JSON.stringify([...static.entries()].reduce((a, c) => (a[c[0]] = getRelative(c[1]), a), {}))}
script
| var staticFiles = new Map(
!= JSON.stringify([...static.keys()].map(k => [k, getStatic(k)]))
| )
link(rel="stylesheet" type="text/css" href=getStatic("/sass/main.sass"))
script(type="module" src=getStatic("/js/groups.js"))
script(type="module" src=getStatic("/js/chat-input.js"))
script(type="module" src=getStatic("/js/room-picker.js"))
script(type="module" src=getStatic("/js/sync/sync.js"))
script(type="module" src=getStatic("/js/chat.js"))
title Carbon
body
script(type="module" src=getStatic("/js/main.js"))
body.show-focus
main.main
.c-groups
.c-groups__display#c-groups-display
@ -53,7 +49,9 @@ html
.c-groups__container#c-groups-list
.c-rooms#c-rooms
.c-chat
.c-chat-banner#c-chat-banner
.c-chat__messages#c-chat-messages
.c-chat__inner#c-chat
.c-chat-input
textarea(placeholder="Send a message..." autocomplete="off").c-chat-input__textarea#c-chat-textarea
textarea(placeholder="Send a message..." autocomplete="off").c-chat-input__textarea#c-chat-textarea
.c-typing#c-typing

View File

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

View File

@ -1,4 +1,4 @@
import {ElemJS} from $to_relative "/js/basic.js"
const {ElemJS} = require("./basic.js")
class Anchor extends ElemJS {
constructor() {
@ -12,4 +12,4 @@ class Anchor extends ElemJS {
}
}
export {Anchor}
module.exports = {Anchor}

View File

@ -19,12 +19,12 @@ const qa = s => document.querySelectorAll(s);
*/
class ElemJS {
constructor(type) {
if (type instanceof HTMLElement) {
// If passed an existing element, bind to it
this.bind(type);
} else {
// Otherwise, create a new detached element to bind to
if (typeof type === "string") {
// Passed a tag name; create an element to bind to
this.bind(document.createElement(type));
} else {
// Passed an existing element; bind to it
this.bind(type);
}
this.children = [];
}
@ -157,4 +157,4 @@ function ejs(tag) {
return new ElemJS(tag);
}
export {q, qa, ElemJS, ejs}
module.exports = {q, qa, ElemJS, ejs}

View File

@ -1,28 +1,93 @@
import {q} from $to_relative "/js/basic.js"
import {store} from $to_relative "/js/store/store.js"
import * as lsm from $to_relative "/js/lsm.js"
import {chat} from $to_relative "/js/chat.js"
const {q} = require("./basic.js")
const {store} = require("./store/store.js")
const lsm = require("./lsm.js")
const {chat} = require("./chat.js")
const {toHTML} = require("discord-markdown")
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", () => {
// stop typing. you semantically can't type in a room you're not in.
typingManager.update(null)
// focus input box
if (store.activeRoom.exists()) {
input.focus()
}
})
input.addEventListener("keydown", event => {
if (!store.activeRoom.exists()) return
// send message?
if (event.key === "Enter" && !event.shiftKey && !event.ctrlKey) {
event.preventDefault()
const body = input.value
send(input.value)
typingManager.update(null) // stop typing
input.value = ""
fixHeight()
return
}
})
input.addEventListener("input", () => {
fixHeight()
// set typing
if (input.value) {
typingManager.update(store.activeRoom.value().id)
} else {
typingManager.update(null)
}
})
function fixHeight() {
@ -33,5 +98,13 @@ function fixHeight() {
function send(body) {
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)
}

View File

@ -1,5 +1,5 @@
import {ElemJS, q, ejs} from $to_relative "/js/basic.js"
import {store} from $to_relative "/js/store/store.js"
const {ElemJS, q, ejs} = require("./basic.js")
const {store} = require("./store/store.js")
const chatMessages = q("#c-chat-messages")
@ -27,7 +27,7 @@ class Chat extends ElemJS {
// connect to the new room's timeline updater
if (store.activeRoom.exists()) {
const timeline = store.activeRoom.value().timeline
const subscription = () => {
const beforeChangeSubscription = () => {
// 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.
// 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)
}
const name = "beforeChange"
this.removableSubscriptions.push({name, target: timeline, subscription})
timeline.subscribe(name, subscription)
this.addSubscription("beforeChange", timeline, beforeChangeSubscription)
// 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()
}
addSubscription(name, target, subscription) {
this.removableSubscriptions.push({name, target, subscription})
target.subscribe(name, subscription)
}
render() {
this.clearChildren()
@ -62,4 +79,4 @@ class Chat extends ElemJS {
const chat = new Chat()
export {chat}
module.exports = {chat}

3
src/js/date-formatter.js Normal file
View 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
View 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]

View 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}

View 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
View 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
View 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
View 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
View 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
View 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]

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

View File

@ -1,7 +1,10 @@
import * as lsm from $to_relative "/js/lsm.js"
const lsm = require("./lsm.js")
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) {
return `${lsm.get("domain")}/_matrix/media/r0/thumbnail/${server}/${id}?width=${size}&height=${size}&method=${method}`
} 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
}

View File

@ -1,4 +1,4 @@
import {q} from $to_relative "/js/basic.js"
const {q} = require("./basic.js")
let state = "CLOSED"

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

View File

@ -8,4 +8,4 @@ function set(name, value) {
window.lsm = {get, set}
export {get, set}
module.exports = {get, set}

11
src/js/main.js Normal file
View 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
View 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
}

View File

@ -1,10 +1,10 @@
import {q, ElemJS, ejs} from $to_relative "/js/basic.js"
import {store} from $to_relative "/js/store/store.js"
import {SubscribeMapList} from $to_relative "/js/store/SubscribeMapList.js"
import {SubscribeValue} from $to_relative "/js/store/SubscribeValue.js"
import {Timeline} from $to_relative "/js/Timeline.js"
import * as lsm from $to_relative "/js/lsm.js"
import {resolveMxc} from $to_relative "/js/functions.js"
const {q, ElemJS, ejs} = require("./basic.js")
const {store} = require("./store/store.js")
const {SubscribeMapList} = require("./store/subscribe_map_list.js")
const {SubscribeValue} = require("./store/subscribe_value.js")
const {Timeline} = require("./timeline.js")
const lsm = require("./lsm.js")
const {resolveMxc, extractLocalpart, extractDisplayName} = require("./functions.js")
class ActiveGroupMarker extends ElemJS {
constructor() {
@ -25,12 +25,43 @@ class ActiveGroupMarker extends ElemJS {
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 {
constructor(key, data) {
super("div")
this.data = data
this.order = this.data.order
this.number = new GroupNotifier()
this.class("c-group")
this.child(
@ -38,6 +69,7 @@ class Group extends ElemJS {
? ejs("img").class("c-group__icon").attribute("src", this.data.icon)
: ejs("div").class("c-group__icon")
),
this.number,
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 {
constructor(id, data) {
super("div")
this.id = id
this.data = data
this.number = new RoomNotifier(this)
this.timeline = new Timeline(this)
this.group = null
this.members = new SubscribeMapList(SubscribeValue)
@ -75,43 +168,80 @@ class Room extends ElemJS {
}
get order() {
if (this.group) {
let chars = 36
let total = 0
const name = this.getName()
for (let i = 0; i < name.length; i++) {
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
let string = ""
if (this.number.state.notifications) {
string += "N"
} else if (this.number.state.unreads) {
string += "U"
} 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() {
// if the room has a name
let name = this.data.state.events.find(e => e.type === "m.room.name")
if (name) {
name = name.content.name
} else {
const users = this.data.summary["m.heroes"]
const usernames = users.map(u => (u.match(/^@([^:]+):/) || [])[1] || u)
name = usernames.join(", ")
if (name && name.content.name) {
return name.content.name
}
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() {
// if the room has a normal avatar
const avatar = this.data.state.events.find(e => e.type === "m.room.avatar")
if (avatar) {
return resolveMxc(avatar.content.url || avatar.content.avatar_url, 32, "crop")
} else {
return null
const url = avatar.content.url || avatar.content.avatar_url
if (url) {
return resolveMxc(url, 32, "crop")
}
}
// 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() {
@ -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__name").text(this.getName()))
this.child(this.number)
// active
const active = store.activeRoom.value() === this
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.directs.subscribe("changeItem", this.render.bind(this))
store.newEvents.subscribe("changeSelf", this.sort.bind(this))
store.notificationsChange.subscribe("changeSelf", this.sort.bind(this))
this.render()
}
@ -223,8 +355,12 @@ class Groups extends ElemJS {
render() {
this.clearChildren()
store.groups.forEach((key, item) => {
item.value().number.clear()
this.child(item.value())
})
store.rooms.forEach((id, room) => {
room.value().number.informGroup() // update group notification number
})
}
}
const groups = new Groups()

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

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

View File

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

View File

@ -1,7 +1,7 @@
import {Subscribable} from $to_relative "/js/store/Subscribable.js"
import {SubscribeMapList} from $to_relative "/js/store/SubscribeMapList.js"
import {SubscribeSet} from $to_relative "/js/store/SubscribeSet.js"
import {SubscribeValue} from $to_relative "/js/store/SubscribeValue.js"
const {Subscribable} = require("./subscribable.js")
const {SubscribeMapList} = require("./subscribe_map_list.js")
const {SubscribeSet} = require("./subscribe_set.js")
const {SubscribeValue} = require("./subscribe_value.js")
const store = {
groups: new SubscribeMapList(SubscribeValue),
@ -9,9 +9,10 @@ const store = {
directs: new SubscribeSet(),
activeGroup: new SubscribeValue(),
activeRoom: new SubscribeValue(),
newEvents: new Subscribable()
newEvents: new Subscribable(),
notificationsChange: new Subscribable()
}
window.store = store
export {store}
module.exports = {store}

View File

@ -20,6 +20,8 @@ class Subscribable {
} else {
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) {
@ -35,4 +37,4 @@ class Subscribable {
}
}
export {Subscribable}
module.exports = {Subscribable}

View 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}

View File

@ -1,5 +1,5 @@
import {Subscribable} from $to_relative "/js/store/Subscribable.js"
import {SubscribeValue} from $to_relative "/js/store/SubscribeValue.js"
const {Subscribable} = require("./subscribable.js")
const {SubscribeValue} = require("./subscribe_value.js")
class SubscribeMapList extends Subscribable {
constructor(inner) {
@ -54,6 +54,15 @@ class SubscribeMapList extends Subscribable {
}
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) => {
const orderA = this.map.get(a).value().order
const orderB = this.map.get(b).value().order
@ -62,6 +71,17 @@ class SubscribeMapList extends Subscribable {
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) {
let s
if (this.map.has(key)) {
@ -83,4 +103,4 @@ class SubscribeMapList extends Subscribable {
}
}
export {SubscribeMapList}
module.exports = {SubscribeMapList}

View File

@ -1,4 +1,4 @@
import {Subscribable} from $to_relative "/js/store/Subscribable.js"
const {Subscribable} = require("./subscribable.js")
class SubscribeSet extends Subscribable {
constructor() {
@ -47,4 +47,4 @@ class SubscribeSet extends Subscribable {
}
}
export {SubscribeSet}
module.exports = {SubscribeSet}

View File

@ -1,4 +1,4 @@
import {Subscribable} from $to_relative "/js/store/Subscribable.js"
const {Subscribable} = require("./subscribable.js")
class SubscribeValue extends Subscribable {
constructor() {
@ -30,7 +30,7 @@ class SubscribeValue extends Subscribable {
edit(f) {
if (this.exists()) {
f(this.data)
this.data = f(this.data)
this.set(this.data)
} else {
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}

View File

@ -1,6 +1,6 @@
import {store} from $to_relative "/js/store/store.js"
import * as lsm from $to_relative "/js/lsm.js"
import {resolveMxc} from $to_relative "/js/functions.js"
const {store} = require("../store/store.js")
const lsm = require("../lsm.js")
const {resolveMxc} = require("../functions.js")
let lastBatch = null
@ -11,7 +11,7 @@ function sync() {
room: {
// pulling more from the timeline massively increases download size
timeline: {
limit: 5
limit: 1
},
// members are not currently needed
state: {
@ -37,63 +37,88 @@ function sync() {
function manageSync(root) {
try {
let newEvents = false
let notificationsChange = false
// set up directs
const directs = root.account_data.events.find(e => e.type === "m.direct")
if (directs) {
Object.values(directs.content).forEach(ids => {
ids.forEach(id => store.directs.add(id))
})
if (root.account_data) {
const directs = root.account_data.events.find(e => e.type === "m.direct")
if (directs) {
Object.values(directs.content).forEach(ids => {
ids.forEach(id => store.directs.add(id))
})
}
}
// set up rooms
Object.entries(root.rooms.join).forEach(([id, data]) => {
if (!store.rooms.has(id)) {
store.rooms.askAdd(id, data)
if (root.rooms) {
if (root.rooms.join) {
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
Promise.all(
Object.keys(root.groups.join).map(id => {
if (!store.groups.has(id)) {
return Promise.all(["profile", "rooms"].map(path => {
const url = new URL(`${lsm.get("domain")}/_matrix/client/r0/groups/${id}/${path}`)
url.searchParams.append("access_token", lsm.get("access_token"))
return fetch(url.toString()).then(res => res.json())
})).then(([profile, rooms]) => {
rooms = rooms.chunk
let order = 999
let orderEvent = root.account_data.events.find(e => e.type === "im.vector.web.tag_ordering")
if (orderEvent) {
if (orderEvent.content.tags.includes(id)) {
order = orderEvent.content.tags.indexOf(id)
if (root.groups) {
Promise.all(
Object.keys(root.groups.join).map(id => {
if (!store.groups.has(id)) {
return Promise.all(["profile", "rooms"].map(path => {
const url = new URL(`${lsm.get("domain")}/_matrix/client/r0/groups/${id}/${path}`)
url.searchParams.append("access_token", lsm.get("access_token"))
return fetch(url.toString()).then(res => res.json())
})).then(([profile, rooms]) => {
rooms = rooms.chunk
let order = 999
let orderEvent = root.account_data.events.find(e => e.type === "im.vector.web.tag_ordering")
if (orderEvent) {
if (orderEvent.content.tags.includes(id)) {
order = orderEvent.content.tags.indexOf(id)
}
}
}
const data = {
name: profile.name,
icon: resolveMxc(profile.avatar_url, 96, "crop"),
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)
const data = {
name: profile.name,
icon: resolveMxc(profile.avatar_url, 96, "crop"),
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.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 (notificationsChange) store.notificationsChange.broadcast("changeSelf")
} catch (e) {
console.error(root)
throw e
@ -121,4 +146,6 @@ function syncLoop() {
store.activeGroup.set(store.groups.get("directs").value())
syncLoop()
if (lsm.get("access_token")) {
syncLoop()
}

408
src/js/timeline.js Normal file
View 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
View 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()

View File

@ -1,21 +1,32 @@
doctype html
html
head
meta(charset="utf-8")
link(rel="stylesheet" type="text/css" href=getStatic("/sass/main.sass"))
title Carbon
body
main.main
form
div
label(for="login") Username
input(type="text" name="login" autocomplete="username" placeholder="example:matrix.org" required)#login
div
label(for="password") Password
input(type="text" name="password" autocomplete="current-password" required)#password
div
head
meta(charset="utf-8")
title Carbon
meta(name="viewport" content="width=device-width, initial-scale=1")
link(rel="stylesheet" type="text/css" href=getStatic("/sass/login.sass"))
script(type="module" src=getStatic("/js/login.js"))
body
main.main
.center-login-container
h1 Welcome to Carbon!
form.login-form(method="post" onsubmit="return false")#form
.data-input
.form-input-container
label(for="username") Username
input(type="text" name="username" autocomplete="username" placeholder="@username:server.tld" pattern="^@?[a-z0-9._=/-]+(?::[a-zA-Z0-9.:\\[\\]-]+)?$" required)#username
.form-input-container
label(for="password") Password
input(name="password" autocomplete="current-password" type="password" required)#password
.form-input-container
label(for="homeserver") Homeserver
input(type="text" name="homeserver" value="matrix.org" placeholder="matrix.org" required)#homeserver
#feedback
.form-input-container
input(type="submit" value="Log in")#submit
label(for="homeserver") Homeserver
input(type="text" name="homeserver" value="matrix.org" required)#homeserver
div
input(type="submit" value="Login")

View File

@ -21,3 +21,39 @@ body
.main
height: 100vh
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

View File

@ -5,3 +5,5 @@ $mild: #393c42
$milder: #42454a
$divider: #4b4e54
$muted: #999
$link: #57bffd
$notify-highlight: #ffac4b

View 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

View File

@ -6,11 +6,14 @@
-webkit-appearance: $value
.c-chat-input
position: relative
width: 100%
border-top: 2px solid c.$divider
background-color: c.$dark
&__textarea
position: relative
z-index: 1
width: calc(100% - 40px)
height: 16px + (16px * 1.45)
box-sizing: border-box

View File

@ -2,11 +2,12 @@
.c-chat
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
flex: 1
&__messages
position: relative
height: 100%
overflow-y: scroll
scrollbar-color: c.$darkest c.$darker

View File

@ -36,11 +36,13 @@ $out-width: $base-width + rooms.$list-width
box-sizing: border-box
.c-group
position: relative
display: flex
align-items: center
padding: $icon-padding / 2 $icon-padding
cursor: pointer
border-radius: 8px
background-color: c.$darkest
&:hover
background-color: c.$darker
@ -62,6 +64,29 @@ $out-width: $base-width + rooms.$list-width
overflow: hidden
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
position: absolute
top: 5px

View File

@ -0,0 +1 @@
@use "../../../node_modules/highlight.js/scss/obsidian"

View File

@ -1,6 +1,6 @@
@use "../colors" as c
.c-event-groups *
.c-event-groups > *
overflow-anchor: none
.c-message-group, .c-message-event
@ -9,7 +9,8 @@
border-top: 1px solid c.$divider
.c-message-group
display: flex
display: grid
grid-template-columns: auto 1fr
&__avatar
flex-shrink: 0
@ -23,7 +24,7 @@
border-radius: 50%
&--no-icon
background-color: #48d
background-color: #bbb
&__intro
display: flex
@ -46,9 +47,19 @@
.c-message
margin-top: 4px
overflow-wrap: anywhere
opacity: 1
transition: opacity 0.2s ease-out
&--plain
white-space: pre-wrap
&--media
// fix whitespace
font-size: 0
margin-top: 8px
display: flex
&--pending
opacity: 0.5
@ -66,18 +77,70 @@
&:hover
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
padding-top: 10px
// closer spacing than normal messages
padding-top: 2px
padding-left: 6px
margin-bottom: -4px
line-height: 1.2
&__inner
display: flex
align-items: center
text-indent: -36px
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
margin-right: 8px
position: relative
top: 1px
&__avatar
width: 16px
height: 16px
border-radius: 50%
margin: 0px 6px
.c-message-notice
padding: 12px
@ -87,3 +150,37 @@
padding: 12px
background-color: c.$milder
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

View 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

View File

@ -43,3 +43,23 @@ $icon-padding: 8px
white-space: nowrap
overflow: hidden
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

View File

@ -0,0 +1,8 @@
.mx-spoiler
color: #331911
background-color: #331911
outline-color: #fff !important
cursor: pointer
&--shown
color: inherit

View 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
View File

@ -0,0 +1,13 @@
@keyframes spin
0%
transform: rotate(0deg)
100%
transform: rotate(180deg)
.loading-icon
display: inline-block
background-color: #ccc
width: 12px
height: 12px
margin-right: 6px
animation: spin 0.7s infinite

73
src/sass/login.sass Normal file
View 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

View File

@ -1,7 +1,13 @@
@use "./base"
@use "./loading"
@use "./components/groups"
@use "./components/rooms"
@use "./components/messages"
@use "./components/chat"
@use "./components/chat-input"
@use "./components/typing"
@use "./components/anchor"
@use "./components/highlighted-code"
@use "./components/read-marker"
@use "./components/chat-banner"
@use "./components/spoilers"