Compare commits
	
		
			107 commits
		
	
	
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 734506a300 | |||
| f802031848 | |||
| 879c09f70b | |||
| 9dce348a4c | |||
| ea6ccc08ee | |||
| 2e91ff8ff2 | |||
| a004e84adc | |||
| b4dfefbac9 | |||
| e6fc1de276 | |||
| 70cae25aa7 | |||
| 6e209bafd6 | |||
| bc861125d8 | |||
| 6297350418 | |||
| 69a9e2ed2f | |||
| b7905bc3be | |||
| babd098d18 | |||
| 229e6903fd | |||
| e90a2c7da8 | |||
| 03c7501bf1 | |||
| 0960ca7e97 | |||
| c0c7278279 | |||
| 9f6c955b63 | |||
| f4b13dbde4 | |||
| c87b6dcaa7 | |||
| eb573fc17c | |||
| f188d66645 | |||
| 4acd806e66 | |||
| 327290e971 | |||
| d6be694d3b | |||
| f6b95b2ebd | |||
| 951a46d8ec | |||
| 6583c192ce | |||
| 34af1be7d1 | |||
| 1fa7da9ebb | |||
| b74f0cc0dd | |||
| 1aebc2c100 | |||
| 017f30be65 | |||
| a7165fe633 | |||
| 8ba9d73b33 | |||
| 9cf0952d3a | |||
| 714147b980 | |||
| ebf6e7ea78 | |||
| 1bf1712684 | |||
| 0738ce4cb1 | |||
| 20e94f05e7 | |||
| 4d59b1a9ac | |||
| 5a41a2c943 | |||
| 20bacce068 | |||
| f80bf36991 | |||
| 217a815750 | |||
| bd9623578f | |||
| c144d75c99 | |||
| 66ecf44048 | |||
| ff196a64bb | |||
| a4c7f29ec9 | |||
| 5bfe98bdf4 | |||
| e08b895694 | |||
| d983385e16 | |||
| f46f9abe6e | |||
| 1a8427925c | |||
| 
							 | 
						098ea88f5d | ||
| 72b42e7b26 | |||
| f4b368ea3e | |||
| 6da9f41519 | |||
| df47c8a88a | |||
| 5ab182e615 | |||
| 08a0990bc8 | |||
| 0348fed18d | |||
| c9dffc9d4a | |||
| 6227f6fa84 | |||
| 51905ab3f2 | |||
| d90be7a0d4 | |||
| 79aa423ebb | |||
| 91ac7a6b3e | |||
| aa12cd68e6 | |||
| 4382928a93 | |||
| 2f5955b043 | |||
| 36f204624f | |||
| 6499cd4ff6 | |||
| e18c8c77ae | |||
| 16de7edd19 | |||
| 0113024be6 | |||
| 1b97351ca0 | |||
| dce4fa6303 | |||
| 2ff43ea801 | |||
| 9e71336c5b | |||
| 61cc4a19f3 | |||
| 0c3c06bc0a | |||
| 6f67ddbce5 | |||
| f1b75f5e10 | |||
| c8351e8f0c | |||
| cf2c691585 | |||
| ff427d0354 | |||
| a56c42311f | |||
| 64c3e18788 | |||
| a7ded5fae3 | |||
| 4869a31ec2 | |||
| 253ccbadc2 | |||
| b83cede3ec | |||
| b9df147db5 | |||
| 184c876fb9 | |||
| 988dd1050b | |||
| 735ca360c8 | |||
| 265d774b4f | |||
| 3fc8104bdd | |||
| c21ab3b90f | |||
| 3e28d4b6e1 | 
							
								
								
									
										33
									
								
								.drone.yml
									
										
									
									
									
								
							
							
						
						| 
						 | 
				
			
			@ -6,16 +6,40 @@ steps:
 | 
			
		|||
  - name: build
 | 
			
		||||
    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
									
									
								
							
							
						
						| 
						 | 
				
			
			@ -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/
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										56
									
								
								README.md
									
										
									
									
									
								
							
							
						
						| 
						 | 
				
			
			@ -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.)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										92
									
								
								build.js
									
										
									
									
									
								
							
							
						
						| 
						 | 
				
			
			@ -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
									
									
									
								
							
							
						
						
							
								
								
									
										12
									
								
								package.json
									
										
									
									
									
								
							
							
						
						| 
						 | 
				
			
			@ -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
									
										
									
									
									
								
							
							
						
						| 
						 | 
				
			
			@ -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",
 | 
			
		||||
	},
 | 
			
		||||
];
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										94
									
								
								src/assets/icons/call-accepted.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,94 @@
 | 
			
		|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
 | 
			
		||||
<svg
 | 
			
		||||
   xmlns:dc="http://purl.org/dc/elements/1.1/"
 | 
			
		||||
   xmlns:cc="http://creativecommons.org/ns#"
 | 
			
		||||
   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
 | 
			
		||||
   xmlns:svg="http://www.w3.org/2000/svg"
 | 
			
		||||
   xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
 | 
			
		||||
   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
 | 
			
		||||
   viewBox="0 0 20 20"
 | 
			
		||||
   version="1.1"
 | 
			
		||||
   id="svg1826"
 | 
			
		||||
   sodipodi:docname="call-accepted.svg"
 | 
			
		||||
   width="20"
 | 
			
		||||
   height="20"
 | 
			
		||||
   inkscape:export-filename="/home/cloud/Code/Carbon/src/assets/icons/call-out-accepted.svg.png"
 | 
			
		||||
   inkscape:export-xdpi="96"
 | 
			
		||||
   inkscape:export-ydpi="96"
 | 
			
		||||
   inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)">
 | 
			
		||||
  <metadata
 | 
			
		||||
     id="metadata1830">
 | 
			
		||||
    <rdf:RDF>
 | 
			
		||||
      <cc:Work
 | 
			
		||||
         rdf:about="">
 | 
			
		||||
        <dc:format>image/svg+xml</dc:format>
 | 
			
		||||
        <dc:type
 | 
			
		||||
           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
 | 
			
		||||
        <dc:title>free-icons-solid</dc:title>
 | 
			
		||||
      </cc:Work>
 | 
			
		||||
    </rdf:RDF>
 | 
			
		||||
  </metadata>
 | 
			
		||||
  <sodipodi:namedview
 | 
			
		||||
     pagecolor="#ffffff"
 | 
			
		||||
     bordercolor="#666666"
 | 
			
		||||
     borderopacity="1"
 | 
			
		||||
     objecttolerance="10"
 | 
			
		||||
     gridtolerance="10"
 | 
			
		||||
     guidetolerance="10"
 | 
			
		||||
     inkscape:pageopacity="0"
 | 
			
		||||
     inkscape:pageshadow="2"
 | 
			
		||||
     inkscape:window-width="1440"
 | 
			
		||||
     inkscape:window-height="879"
 | 
			
		||||
     id="namedview1828"
 | 
			
		||||
     showgrid="true"
 | 
			
		||||
     inkscape:snap-global="true"
 | 
			
		||||
     inkscape:snap-bbox="false"
 | 
			
		||||
     inkscape:snap-bbox-edge-midpoints="true"
 | 
			
		||||
     inkscape:bbox-nodes="true"
 | 
			
		||||
     inkscape:bbox-paths="false"
 | 
			
		||||
     inkscape:object-paths="false"
 | 
			
		||||
     inkscape:snap-intersection-paths="true"
 | 
			
		||||
     inkscape:snap-midpoints="false"
 | 
			
		||||
     inkscape:snap-smooth-nodes="false"
 | 
			
		||||
     showguides="true"
 | 
			
		||||
     inkscape:zoom="32"
 | 
			
		||||
     inkscape:cx="7.9954672"
 | 
			
		||||
     inkscape:cy="9.9614234"
 | 
			
		||||
     inkscape:window-x="0"
 | 
			
		||||
     inkscape:window-y="0"
 | 
			
		||||
     inkscape:window-maximized="1"
 | 
			
		||||
     inkscape:current-layer="svg1826">
 | 
			
		||||
    <inkscape:grid
 | 
			
		||||
       type="xygrid"
 | 
			
		||||
       id="grid2773"
 | 
			
		||||
       originx="-158"
 | 
			
		||||
       originy="-1412" />
 | 
			
		||||
  </sodipodi:namedview>
 | 
			
		||||
  <defs
 | 
			
		||||
     id="defs4">
 | 
			
		||||
    <style
 | 
			
		||||
       id="style2">.a{fill:#757575;}.b{fill:#b4b4b4;}.c{fill:#767676;}</style>
 | 
			
		||||
  </defs>
 | 
			
		||||
  <title
 | 
			
		||||
     id="title6">free-icons-solid</title>
 | 
			
		||||
  <path
 | 
			
		||||
     inkscape:connector-curvature="0"
 | 
			
		||||
     class="a"
 | 
			
		||||
     d="m 15,13 h -3 c -0.460994,-2.45e-4 -1.000244,0.539006 -1,1 l 0.0029,0.66952 C 7.75933,14.711832 4.2952877,11.24779 4.3376001,8.004177 L 5,8 C 5.4609939,8.000244 6.0002445,7.4609939 6,7 V 4 C 6.0002445,3.5390061 5.4609939,2.9997555 5,3 H 3 C 2.5390061,2.9997555 1.9997555,3.5390061 2,4 v 4 c -4.15e-5,5.069836 3.9301639,9.000041 9,9 h 4 c 0.460994,2.45e-4 1.000245,-0.539006 1,-1 v -2 c 2.45e-4,-0.460994 -0.539006,-1.000245 -1,-1 z"
 | 
			
		||||
     id="path226"
 | 
			
		||||
     style="fill:#73d216;fill-opacity:1;stroke:none;stroke-width:0.99999994"
 | 
			
		||||
     sodipodi:nodetypes="ccccccccccccccccc" />
 | 
			
		||||
  <path
 | 
			
		||||
     style="opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#73d216;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill"
 | 
			
		||||
     d="m 9,7 c 2,0 3,1 3,3"
 | 
			
		||||
     id="path3644"
 | 
			
		||||
     inkscape:connector-curvature="0"
 | 
			
		||||
     sodipodi:nodetypes="cc" />
 | 
			
		||||
  <path
 | 
			
		||||
     sodipodi:nodetypes="cc"
 | 
			
		||||
     inkscape:connector-curvature="0"
 | 
			
		||||
     id="path3646"
 | 
			
		||||
     d="m 9,4 c 4,0 6,2 6,6"
 | 
			
		||||
     style="opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#73d216;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill" />
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 3.6 KiB  | 
							
								
								
									
										99
									
								
								src/assets/icons/call-in.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,99 @@
 | 
			
		|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
 | 
			
		||||
<svg
 | 
			
		||||
   xmlns:dc="http://purl.org/dc/elements/1.1/"
 | 
			
		||||
   xmlns:cc="http://creativecommons.org/ns#"
 | 
			
		||||
   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
 | 
			
		||||
   xmlns:svg="http://www.w3.org/2000/svg"
 | 
			
		||||
   xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
 | 
			
		||||
   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
 | 
			
		||||
   viewBox="0 0 20 20"
 | 
			
		||||
   version="1.1"
 | 
			
		||||
   id="svg1826"
 | 
			
		||||
   sodipodi:docname="call-in.svg"
 | 
			
		||||
   width="20"
 | 
			
		||||
   height="20"
 | 
			
		||||
   inkscape:export-filename="/home/cloud/Code/Carbon/src/assets/icons/call-out-accepted.svg.png"
 | 
			
		||||
   inkscape:export-xdpi="96"
 | 
			
		||||
   inkscape:export-ydpi="96"
 | 
			
		||||
   inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)">
 | 
			
		||||
  <metadata
 | 
			
		||||
     id="metadata1830">
 | 
			
		||||
    <rdf:RDF>
 | 
			
		||||
      <cc:Work
 | 
			
		||||
         rdf:about="">
 | 
			
		||||
        <dc:format>image/svg+xml</dc:format>
 | 
			
		||||
        <dc:type
 | 
			
		||||
           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
 | 
			
		||||
        <dc:title>free-icons-solid</dc:title>
 | 
			
		||||
      </cc:Work>
 | 
			
		||||
    </rdf:RDF>
 | 
			
		||||
  </metadata>
 | 
			
		||||
  <sodipodi:namedview
 | 
			
		||||
     pagecolor="#ffffff"
 | 
			
		||||
     bordercolor="#666666"
 | 
			
		||||
     borderopacity="1"
 | 
			
		||||
     objecttolerance="10"
 | 
			
		||||
     gridtolerance="10"
 | 
			
		||||
     guidetolerance="10"
 | 
			
		||||
     inkscape:pageopacity="0"
 | 
			
		||||
     inkscape:pageshadow="2"
 | 
			
		||||
     inkscape:window-width="1440"
 | 
			
		||||
     inkscape:window-height="879"
 | 
			
		||||
     id="namedview1828"
 | 
			
		||||
     showgrid="true"
 | 
			
		||||
     inkscape:snap-global="true"
 | 
			
		||||
     inkscape:snap-bbox="false"
 | 
			
		||||
     inkscape:snap-bbox-edge-midpoints="true"
 | 
			
		||||
     inkscape:bbox-nodes="true"
 | 
			
		||||
     inkscape:bbox-paths="false"
 | 
			
		||||
     inkscape:object-paths="false"
 | 
			
		||||
     inkscape:snap-intersection-paths="true"
 | 
			
		||||
     inkscape:snap-midpoints="false"
 | 
			
		||||
     inkscape:snap-smooth-nodes="false"
 | 
			
		||||
     showguides="true"
 | 
			
		||||
     inkscape:zoom="16"
 | 
			
		||||
     inkscape:cx="9.4645773"
 | 
			
		||||
     inkscape:cy="12.891863"
 | 
			
		||||
     inkscape:window-x="0"
 | 
			
		||||
     inkscape:window-y="0"
 | 
			
		||||
     inkscape:window-maximized="1"
 | 
			
		||||
     inkscape:current-layer="svg1826">
 | 
			
		||||
    <inkscape:grid
 | 
			
		||||
       type="xygrid"
 | 
			
		||||
       id="grid2773"
 | 
			
		||||
       originx="-158"
 | 
			
		||||
       originy="-1412" />
 | 
			
		||||
  </sodipodi:namedview>
 | 
			
		||||
  <defs
 | 
			
		||||
     id="defs4">
 | 
			
		||||
    <style
 | 
			
		||||
       id="style2">.a{fill:#757575;}.b{fill:#b4b4b4;}.c{fill:#767676;}</style>
 | 
			
		||||
  </defs>
 | 
			
		||||
  <title
 | 
			
		||||
     id="title6">free-icons-solid</title>
 | 
			
		||||
  <path
 | 
			
		||||
     inkscape:connector-curvature="0"
 | 
			
		||||
     class="a"
 | 
			
		||||
     d="m 15,15 h -3 c -0.460994,-2.45e-4 -1.000244,0.539006 -1,1 l 0.0029,0.66952 C 7.75933,16.711832 4.2952877,13.24779 4.3376001,10.004177 L 5,10 C 5.4609939,10.000244 6.0002445,9.4609939 6,9 V 6 C 6.0002445,5.5390061 5.4609939,4.9997555 5,5 H 3 C 2.5390061,4.9997555 1.9997555,5.5390061 2,6 v 4 c -4.15e-5,5.069836 3.9301639,9.000041 9,9 h 4 c 0.460994,2.45e-4 1.000245,-0.539006 1,-1 v -2 c 2.45e-4,-0.460994 -0.539006,-1.000245 -1,-1 z"
 | 
			
		||||
     id="path226"
 | 
			
		||||
     style="fill:#d591c6;fill-opacity:1;stroke:none;stroke-width:0.99999994"
 | 
			
		||||
     sodipodi:nodetypes="ccccccccccccccccc" />
 | 
			
		||||
  <g
 | 
			
		||||
     id="g2813"
 | 
			
		||||
     style="stroke:#d591c6;stroke-opacity:1"
 | 
			
		||||
     transform="matrix(1,0,0,-1,0,13.004142)">
 | 
			
		||||
    <path
 | 
			
		||||
       sodipodi:nodetypes="ccc"
 | 
			
		||||
       inkscape:connector-curvature="0"
 | 
			
		||||
       id="path2807"
 | 
			
		||||
       d="M 13,11 V 2 l -3,3"
 | 
			
		||||
       style="opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#d591c6;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill" />
 | 
			
		||||
    <path
 | 
			
		||||
       sodipodi:nodetypes="cc"
 | 
			
		||||
       inkscape:connector-curvature="0"
 | 
			
		||||
       id="path2809"
 | 
			
		||||
       d="m 13,2 3,3"
 | 
			
		||||
       style="opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#d591c6;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill" />
 | 
			
		||||
  </g>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 3.7 KiB  | 
							
								
								
									
										98
									
								
								src/assets/icons/call-out.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,98 @@
 | 
			
		|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
 | 
			
		||||
<svg
 | 
			
		||||
   xmlns:dc="http://purl.org/dc/elements/1.1/"
 | 
			
		||||
   xmlns:cc="http://creativecommons.org/ns#"
 | 
			
		||||
   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
 | 
			
		||||
   xmlns:svg="http://www.w3.org/2000/svg"
 | 
			
		||||
   xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
 | 
			
		||||
   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
 | 
			
		||||
   viewBox="0 0 20 20"
 | 
			
		||||
   version="1.1"
 | 
			
		||||
   id="svg1826"
 | 
			
		||||
   sodipodi:docname="call-out.svg"
 | 
			
		||||
   width="20"
 | 
			
		||||
   height="20"
 | 
			
		||||
   inkscape:export-filename="/home/cloud/Code/Carbon/src/assets/icons/call-out-accepted.svg.png"
 | 
			
		||||
   inkscape:export-xdpi="96"
 | 
			
		||||
   inkscape:export-ydpi="96"
 | 
			
		||||
   inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)">
 | 
			
		||||
  <metadata
 | 
			
		||||
     id="metadata1830">
 | 
			
		||||
    <rdf:RDF>
 | 
			
		||||
      <cc:Work
 | 
			
		||||
         rdf:about="">
 | 
			
		||||
        <dc:format>image/svg+xml</dc:format>
 | 
			
		||||
        <dc:type
 | 
			
		||||
           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
 | 
			
		||||
        <dc:title>free-icons-solid</dc:title>
 | 
			
		||||
      </cc:Work>
 | 
			
		||||
    </rdf:RDF>
 | 
			
		||||
  </metadata>
 | 
			
		||||
  <sodipodi:namedview
 | 
			
		||||
     pagecolor="#ffffff"
 | 
			
		||||
     bordercolor="#666666"
 | 
			
		||||
     borderopacity="1"
 | 
			
		||||
     objecttolerance="10"
 | 
			
		||||
     gridtolerance="10"
 | 
			
		||||
     guidetolerance="10"
 | 
			
		||||
     inkscape:pageopacity="0"
 | 
			
		||||
     inkscape:pageshadow="2"
 | 
			
		||||
     inkscape:window-width="1440"
 | 
			
		||||
     inkscape:window-height="879"
 | 
			
		||||
     id="namedview1828"
 | 
			
		||||
     showgrid="true"
 | 
			
		||||
     inkscape:snap-global="true"
 | 
			
		||||
     inkscape:snap-bbox="false"
 | 
			
		||||
     inkscape:snap-bbox-edge-midpoints="true"
 | 
			
		||||
     inkscape:bbox-nodes="true"
 | 
			
		||||
     inkscape:bbox-paths="false"
 | 
			
		||||
     inkscape:object-paths="false"
 | 
			
		||||
     inkscape:snap-intersection-paths="true"
 | 
			
		||||
     inkscape:snap-midpoints="false"
 | 
			
		||||
     inkscape:snap-smooth-nodes="false"
 | 
			
		||||
     showguides="true"
 | 
			
		||||
     inkscape:zoom="16"
 | 
			
		||||
     inkscape:cx="9.4645773"
 | 
			
		||||
     inkscape:cy="12.891863"
 | 
			
		||||
     inkscape:window-x="0"
 | 
			
		||||
     inkscape:window-y="0"
 | 
			
		||||
     inkscape:window-maximized="1"
 | 
			
		||||
     inkscape:current-layer="svg1826">
 | 
			
		||||
    <inkscape:grid
 | 
			
		||||
       type="xygrid"
 | 
			
		||||
       id="grid2773"
 | 
			
		||||
       originx="-158"
 | 
			
		||||
       originy="-1412" />
 | 
			
		||||
  </sodipodi:namedview>
 | 
			
		||||
  <defs
 | 
			
		||||
     id="defs4">
 | 
			
		||||
    <style
 | 
			
		||||
       id="style2">.a{fill:#757575;}.b{fill:#b4b4b4;}.c{fill:#767676;}</style>
 | 
			
		||||
  </defs>
 | 
			
		||||
  <title
 | 
			
		||||
     id="title6">free-icons-solid</title>
 | 
			
		||||
  <path
 | 
			
		||||
     inkscape:connector-curvature="0"
 | 
			
		||||
     class="a"
 | 
			
		||||
     d="m 15,15 h -3 c -0.460994,-2.45e-4 -1.000244,0.539006 -1,1 l 0.0029,0.66952 C 7.75933,16.711832 4.2952877,13.24779 4.3376001,10.004177 L 5,10 C 5.4609939,10.000244 6.0002445,9.4609939 6,9 V 6 C 6.0002445,5.5390061 5.4609939,4.9997555 5,5 H 3 C 2.5390061,4.9997555 1.9997555,5.5390061 2,6 v 4 c -4.15e-5,5.069836 3.9301639,9.000041 9,9 h 4 c 0.460994,2.45e-4 1.000245,-0.539006 1,-1 v -2 c 2.45e-4,-0.460994 -0.539006,-1.000245 -1,-1 z"
 | 
			
		||||
     id="path226"
 | 
			
		||||
     style="fill:#d591c6;fill-opacity:1;stroke:none;stroke-width:0.99999994"
 | 
			
		||||
     sodipodi:nodetypes="ccccccccccccccccc" />
 | 
			
		||||
  <g
 | 
			
		||||
     id="g2813"
 | 
			
		||||
     style="stroke:#d591c6;stroke-opacity:1">
 | 
			
		||||
    <path
 | 
			
		||||
       sodipodi:nodetypes="ccc"
 | 
			
		||||
       inkscape:connector-curvature="0"
 | 
			
		||||
       id="path2807"
 | 
			
		||||
       d="M 13,11 V 2 l -3,3"
 | 
			
		||||
       style="opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#d591c6;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill" />
 | 
			
		||||
    <path
 | 
			
		||||
       sodipodi:nodetypes="cc"
 | 
			
		||||
       inkscape:connector-curvature="0"
 | 
			
		||||
       id="path2809"
 | 
			
		||||
       d="m 13,2 3,3"
 | 
			
		||||
       style="opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#d591c6;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill" />
 | 
			
		||||
  </g>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 3.7 KiB  | 
							
								
								
									
										98
									
								
								src/assets/icons/call-rejected.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,98 @@
 | 
			
		|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
 | 
			
		||||
<svg
 | 
			
		||||
   xmlns:dc="http://purl.org/dc/elements/1.1/"
 | 
			
		||||
   xmlns:cc="http://creativecommons.org/ns#"
 | 
			
		||||
   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
 | 
			
		||||
   xmlns:svg="http://www.w3.org/2000/svg"
 | 
			
		||||
   xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
 | 
			
		||||
   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
 | 
			
		||||
   viewBox="0 0 20 20"
 | 
			
		||||
   version="1.1"
 | 
			
		||||
   id="svg1826"
 | 
			
		||||
   sodipodi:docname="call-rejected.svg"
 | 
			
		||||
   width="20"
 | 
			
		||||
   height="20"
 | 
			
		||||
   inkscape:export-filename="/home/cloud/Code/Carbon/src/assets/icons/call-out-accepted.svg.png"
 | 
			
		||||
   inkscape:export-xdpi="96"
 | 
			
		||||
   inkscape:export-ydpi="96"
 | 
			
		||||
   inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)">
 | 
			
		||||
  <metadata
 | 
			
		||||
     id="metadata1830">
 | 
			
		||||
    <rdf:RDF>
 | 
			
		||||
      <cc:Work
 | 
			
		||||
         rdf:about="">
 | 
			
		||||
        <dc:format>image/svg+xml</dc:format>
 | 
			
		||||
        <dc:type
 | 
			
		||||
           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
 | 
			
		||||
        <dc:title>free-icons-solid</dc:title>
 | 
			
		||||
      </cc:Work>
 | 
			
		||||
    </rdf:RDF>
 | 
			
		||||
  </metadata>
 | 
			
		||||
  <sodipodi:namedview
 | 
			
		||||
     pagecolor="#ffffff"
 | 
			
		||||
     bordercolor="#666666"
 | 
			
		||||
     borderopacity="1"
 | 
			
		||||
     objecttolerance="10"
 | 
			
		||||
     gridtolerance="10"
 | 
			
		||||
     guidetolerance="10"
 | 
			
		||||
     inkscape:pageopacity="0"
 | 
			
		||||
     inkscape:pageshadow="2"
 | 
			
		||||
     inkscape:window-width="1440"
 | 
			
		||||
     inkscape:window-height="879"
 | 
			
		||||
     id="namedview1828"
 | 
			
		||||
     showgrid="true"
 | 
			
		||||
     inkscape:snap-global="true"
 | 
			
		||||
     inkscape:snap-bbox="false"
 | 
			
		||||
     inkscape:snap-bbox-edge-midpoints="true"
 | 
			
		||||
     inkscape:bbox-nodes="true"
 | 
			
		||||
     inkscape:bbox-paths="false"
 | 
			
		||||
     inkscape:object-paths="false"
 | 
			
		||||
     inkscape:snap-intersection-paths="true"
 | 
			
		||||
     inkscape:snap-midpoints="false"
 | 
			
		||||
     inkscape:snap-smooth-nodes="false"
 | 
			
		||||
     showguides="true"
 | 
			
		||||
     inkscape:zoom="22.627417"
 | 
			
		||||
     inkscape:cx="12.65156"
 | 
			
		||||
     inkscape:cy="10.798274"
 | 
			
		||||
     inkscape:window-x="0"
 | 
			
		||||
     inkscape:window-y="0"
 | 
			
		||||
     inkscape:window-maximized="1"
 | 
			
		||||
     inkscape:current-layer="svg1826">
 | 
			
		||||
    <inkscape:grid
 | 
			
		||||
       type="xygrid"
 | 
			
		||||
       id="grid2773"
 | 
			
		||||
       originx="-158"
 | 
			
		||||
       originy="-1412" />
 | 
			
		||||
  </sodipodi:namedview>
 | 
			
		||||
  <defs
 | 
			
		||||
     id="defs4">
 | 
			
		||||
    <style
 | 
			
		||||
       id="style2">.a{fill:#757575;}.b{fill:#b4b4b4;}.c{fill:#767676;}</style>
 | 
			
		||||
  </defs>
 | 
			
		||||
  <title
 | 
			
		||||
     id="title6">free-icons-solid</title>
 | 
			
		||||
  <path
 | 
			
		||||
     inkscape:connector-curvature="0"
 | 
			
		||||
     class="a"
 | 
			
		||||
     d="m 15,13 h -3 c -0.460994,-2.45e-4 -1.000244,0.539006 -1,1 l 0.0029,0.66952 C 7.75933,14.711832 4.2952877,11.24779 4.3376001,8.004177 L 5,8 C 5.4609939,8.000244 6.0002445,7.4609939 6,7 V 4 C 6.0002445,3.5390061 5.4609939,2.9997555 5,3 H 3 C 2.5390061,2.9997555 1.9997555,3.5390061 2,4 v 4 c -4.15e-5,5.069836 3.9301639,9.000041 9,9 h 4 c 0.460994,2.45e-4 1.000245,-0.539006 1,-1 v -2 c 2.45e-4,-0.460994 -0.539006,-1.000245 -1,-1 z"
 | 
			
		||||
     id="path226"
 | 
			
		||||
     style="fill:#f43f3f;fill-opacity:1;stroke:none;stroke-width:0.99999994"
 | 
			
		||||
     sodipodi:nodetypes="ccccccccccccccccc" />
 | 
			
		||||
  <g
 | 
			
		||||
     id="g2927"
 | 
			
		||||
     style="stroke:#f43f3f;stroke-opacity:1">
 | 
			
		||||
    <path
 | 
			
		||||
       sodipodi:nodetypes="cc"
 | 
			
		||||
       inkscape:connector-curvature="0"
 | 
			
		||||
       id="path2872"
 | 
			
		||||
       d="M 11,8 16,3"
 | 
			
		||||
       style="opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#f43f3f;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill" />
 | 
			
		||||
    <path
 | 
			
		||||
       style="opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#f43f3f;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill"
 | 
			
		||||
       d="M 16,8 11,3"
 | 
			
		||||
       id="path2923"
 | 
			
		||||
       inkscape:connector-curvature="0"
 | 
			
		||||
       sodipodi:nodetypes="cc" />
 | 
			
		||||
  </g>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 3.7 KiB  | 
							
								
								
									
										81
									
								
								src/assets/icons/invite-event.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,81 @@
 | 
			
		|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
 | 
			
		||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
 | 
			
		||||
 | 
			
		||||
<svg
 | 
			
		||||
   xmlns:dc="http://purl.org/dc/elements/1.1/"
 | 
			
		||||
   xmlns:cc="http://creativecommons.org/ns#"
 | 
			
		||||
   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
 | 
			
		||||
   xmlns:svg="http://www.w3.org/2000/svg"
 | 
			
		||||
   xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
 | 
			
		||||
   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
 | 
			
		||||
   width="20"
 | 
			
		||||
   height="20"
 | 
			
		||||
   viewBox="0 0 5.2916665 5.2916668"
 | 
			
		||||
   version="1.1"
 | 
			
		||||
   id="svg27"
 | 
			
		||||
   inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
 | 
			
		||||
   sodipodi:docname="invite-event.svg">
 | 
			
		||||
  <defs
 | 
			
		||||
     id="defs21" />
 | 
			
		||||
  <sodipodi:namedview
 | 
			
		||||
     id="base"
 | 
			
		||||
     pagecolor="#ffffff"
 | 
			
		||||
     bordercolor="#666666"
 | 
			
		||||
     borderopacity="1.0"
 | 
			
		||||
     inkscape:pageopacity="0.0"
 | 
			
		||||
     inkscape:pageshadow="2"
 | 
			
		||||
     inkscape:zoom="16"
 | 
			
		||||
     inkscape:cx="12.866591"
 | 
			
		||||
     inkscape:cy="7.092849"
 | 
			
		||||
     inkscape:document-units="px"
 | 
			
		||||
     inkscape:current-layer="layer1"
 | 
			
		||||
     showgrid="true"
 | 
			
		||||
     units="px"
 | 
			
		||||
     inkscape:window-width="1440"
 | 
			
		||||
     inkscape:window-height="879"
 | 
			
		||||
     inkscape:window-x="0"
 | 
			
		||||
     inkscape:window-y="0"
 | 
			
		||||
     inkscape:window-maximized="1"
 | 
			
		||||
     inkscape:snap-global="false"
 | 
			
		||||
     inkscape:snap-bbox="true"
 | 
			
		||||
     inkscape:bbox-nodes="true">
 | 
			
		||||
    <inkscape:grid
 | 
			
		||||
       type="xygrid"
 | 
			
		||||
       id="grid26" />
 | 
			
		||||
  </sodipodi:namedview>
 | 
			
		||||
  <metadata
 | 
			
		||||
     id="metadata24">
 | 
			
		||||
    <rdf:RDF>
 | 
			
		||||
      <cc:Work
 | 
			
		||||
         rdf:about="">
 | 
			
		||||
        <dc:format>image/svg+xml</dc:format>
 | 
			
		||||
        <dc:type
 | 
			
		||||
           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
 | 
			
		||||
        <dc:title></dc:title>
 | 
			
		||||
      </cc:Work>
 | 
			
		||||
    </rdf:RDF>
 | 
			
		||||
  </metadata>
 | 
			
		||||
  <g
 | 
			
		||||
     inkscape:label="Layer 1"
 | 
			
		||||
     inkscape:groupmode="layer"
 | 
			
		||||
     id="layer1"
 | 
			
		||||
     transform="translate(0,-291.70832)">
 | 
			
		||||
    <path
 | 
			
		||||
       sodipodi:type="star"
 | 
			
		||||
       style="opacity:1;fill:#fce94f;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.50955456;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill"
 | 
			
		||||
       id="path1026"
 | 
			
		||||
       sodipodi:sides="4"
 | 
			
		||||
       sodipodi:cx="2.5607762"
 | 
			
		||||
       sodipodi:cy="294.50937"
 | 
			
		||||
       sodipodi:r1="2.1649818"
 | 
			
		||||
       sodipodi:r2="0.86599272"
 | 
			
		||||
       sodipodi:arg1="0.78539816"
 | 
			
		||||
       sodipodi:arg2="1.5707963"
 | 
			
		||||
       inkscape:flatsided="false"
 | 
			
		||||
       inkscape:rounded="0"
 | 
			
		||||
       inkscape:randomized="0"
 | 
			
		||||
       d="m 4.0916496,296.04024 -1.5308733,-0.66488 -1.5308734,0.66488 0.6648806,-1.53087 -0.6648806,-1.53087 1.5308733,0.66488 1.5308734,-0.66488 -0.6648806,1.53087 z"
 | 
			
		||||
       transform="matrix(0.73526681,0.7333768,-0.7333768,0.73526681,216.88378,75.810398)" />
 | 
			
		||||
  </g>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 2.7 KiB  | 
| 
						 | 
				
			
			@ -25,9 +25,9 @@
 | 
			
		|||
     borderopacity="1.0"
 | 
			
		||||
     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  | 
							
								
								
									
										80
									
								
								src/assets/icons/leave-event.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,80 @@
 | 
			
		|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
 | 
			
		||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
 | 
			
		||||
 | 
			
		||||
<svg
 | 
			
		||||
   xmlns:dc="http://purl.org/dc/elements/1.1/"
 | 
			
		||||
   xmlns:cc="http://creativecommons.org/ns#"
 | 
			
		||||
   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
 | 
			
		||||
   xmlns:svg="http://www.w3.org/2000/svg"
 | 
			
		||||
   xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
 | 
			
		||||
   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
 | 
			
		||||
   width="20"
 | 
			
		||||
   height="20"
 | 
			
		||||
   viewBox="0 0 5.2916665 5.2916668"
 | 
			
		||||
   version="1.1"
 | 
			
		||||
   id="svg27"
 | 
			
		||||
   inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
 | 
			
		||||
   sodipodi:docname="leave-event.svg">
 | 
			
		||||
  <defs
 | 
			
		||||
     id="defs21" />
 | 
			
		||||
  <sodipodi:namedview
 | 
			
		||||
     id="base"
 | 
			
		||||
     pagecolor="#ffffff"
 | 
			
		||||
     bordercolor="#666666"
 | 
			
		||||
     borderopacity="1.0"
 | 
			
		||||
     inkscape:pageopacity="0.0"
 | 
			
		||||
     inkscape:pageshadow="2"
 | 
			
		||||
     inkscape:zoom="11.313708"
 | 
			
		||||
     inkscape:cx="-4.2728481"
 | 
			
		||||
     inkscape:cy="-2.1951295"
 | 
			
		||||
     inkscape:document-units="px"
 | 
			
		||||
     inkscape:current-layer="layer1"
 | 
			
		||||
     showgrid="true"
 | 
			
		||||
     units="px"
 | 
			
		||||
     inkscape:window-width="1440"
 | 
			
		||||
     inkscape:window-height="879"
 | 
			
		||||
     inkscape:window-x="0"
 | 
			
		||||
     inkscape:window-y="0"
 | 
			
		||||
     inkscape:window-maximized="1">
 | 
			
		||||
    <inkscape:grid
 | 
			
		||||
       type="xygrid"
 | 
			
		||||
       id="grid26" />
 | 
			
		||||
  </sodipodi:namedview>
 | 
			
		||||
  <metadata
 | 
			
		||||
     id="metadata24">
 | 
			
		||||
    <rdf:RDF>
 | 
			
		||||
      <cc:Work
 | 
			
		||||
         rdf:about="">
 | 
			
		||||
        <dc:format>image/svg+xml</dc:format>
 | 
			
		||||
        <dc:type
 | 
			
		||||
           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
 | 
			
		||||
        <dc:title></dc:title>
 | 
			
		||||
      </cc:Work>
 | 
			
		||||
    </rdf:RDF>
 | 
			
		||||
  </metadata>
 | 
			
		||||
  <g
 | 
			
		||||
     inkscape:label="Layer 1"
 | 
			
		||||
     inkscape:groupmode="layer"
 | 
			
		||||
     id="layer1"
 | 
			
		||||
     transform="translate(0,-291.70832)">
 | 
			
		||||
    <path
 | 
			
		||||
       style="opacity:1;fill:#e2e2e2;fill-opacity:1;fill-rule:nonzero;stroke:#f43f3f;stroke-width:0.52916664;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
 | 
			
		||||
       d="M 4.4979167,294.35416 H 0.79374997"
 | 
			
		||||
       id="path28"
 | 
			
		||||
       inkscape:connector-curvature="0"
 | 
			
		||||
       sodipodi:nodetypes="cc" />
 | 
			
		||||
    <path
 | 
			
		||||
       inkscape:connector-curvature="0"
 | 
			
		||||
       id="path30"
 | 
			
		||||
       d="m 2.1166667,293.03124 -1.32291673,1.32292"
 | 
			
		||||
       style="opacity:1;fill:#e2e2e2;fill-opacity:1;fill-rule:nonzero;stroke:#f43f3f;stroke-width:0.52916664;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
 | 
			
		||||
       sodipodi:nodetypes="cc" />
 | 
			
		||||
    <path
 | 
			
		||||
       sodipodi:nodetypes="cc"
 | 
			
		||||
       style="opacity:1;fill:#e2e2e2;fill-opacity:1;fill-rule:nonzero;stroke:#f43f3f;stroke-width:0.52916664;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
 | 
			
		||||
       d="m 0.79374997,294.35416 1.32291673,1.32291"
 | 
			
		||||
       id="path32"
 | 
			
		||||
       inkscape:connector-curvature="0" />
 | 
			
		||||
  </g>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 2.9 KiB  | 
							
								
								
									
										83
									
								
								src/assets/icons/profile-event.svg
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,83 @@
 | 
			
		|||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
 | 
			
		||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
 | 
			
		||||
 | 
			
		||||
<svg
 | 
			
		||||
   xmlns:dc="http://purl.org/dc/elements/1.1/"
 | 
			
		||||
   xmlns:cc="http://creativecommons.org/ns#"
 | 
			
		||||
   xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
 | 
			
		||||
   xmlns:svg="http://www.w3.org/2000/svg"
 | 
			
		||||
   xmlns="http://www.w3.org/2000/svg"
 | 
			
		||||
   xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
 | 
			
		||||
   xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
 | 
			
		||||
   width="20"
 | 
			
		||||
   height="20"
 | 
			
		||||
   viewBox="0 0 5.2916665 5.2916668"
 | 
			
		||||
   version="1.1"
 | 
			
		||||
   id="svg27"
 | 
			
		||||
   inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
 | 
			
		||||
   sodipodi:docname="profile-event.svg">
 | 
			
		||||
  <defs
 | 
			
		||||
     id="defs21" />
 | 
			
		||||
  <sodipodi:namedview
 | 
			
		||||
     id="base"
 | 
			
		||||
     pagecolor="#ffffff"
 | 
			
		||||
     bordercolor="#666666"
 | 
			
		||||
     borderopacity="1.0"
 | 
			
		||||
     inkscape:pageopacity="0.0"
 | 
			
		||||
     inkscape:pageshadow="2"
 | 
			
		||||
     inkscape:zoom="16"
 | 
			
		||||
     inkscape:cx="8.674554"
 | 
			
		||||
     inkscape:cy="12.76461"
 | 
			
		||||
     inkscape:document-units="px"
 | 
			
		||||
     inkscape:current-layer="layer1"
 | 
			
		||||
     showgrid="true"
 | 
			
		||||
     units="px"
 | 
			
		||||
     inkscape:window-width="1440"
 | 
			
		||||
     inkscape:window-height="879"
 | 
			
		||||
     inkscape:window-x="0"
 | 
			
		||||
     inkscape:window-y="0"
 | 
			
		||||
     inkscape:window-maximized="1"
 | 
			
		||||
     inkscape:snap-global="true"
 | 
			
		||||
     inkscape:snap-bbox="true"
 | 
			
		||||
     inkscape:bbox-nodes="true">
 | 
			
		||||
    <inkscape:grid
 | 
			
		||||
       type="xygrid"
 | 
			
		||||
       id="grid26" />
 | 
			
		||||
  </sodipodi:namedview>
 | 
			
		||||
  <metadata
 | 
			
		||||
     id="metadata24">
 | 
			
		||||
    <rdf:RDF>
 | 
			
		||||
      <cc:Work
 | 
			
		||||
         rdf:about="">
 | 
			
		||||
        <dc:format>image/svg+xml</dc:format>
 | 
			
		||||
        <dc:type
 | 
			
		||||
           rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
 | 
			
		||||
        <dc:title></dc:title>
 | 
			
		||||
      </cc:Work>
 | 
			
		||||
    </rdf:RDF>
 | 
			
		||||
  </metadata>
 | 
			
		||||
  <g
 | 
			
		||||
     inkscape:label="Layer 1"
 | 
			
		||||
     inkscape:groupmode="layer"
 | 
			
		||||
     id="layer1"
 | 
			
		||||
     transform="translate(0,-291.70832)">
 | 
			
		||||
    <path
 | 
			
		||||
       style="opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#729fcf;stroke-width:0.52916664;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill"
 | 
			
		||||
       d="m 2.5761745,295.93007 c -0.6374174,0.0773 -1.2586148,-0.23706 -1.5739225,-0.79639 -0.31531561,-0.55933 -0.26263464,-1.25353 0.1334534,-1.75888 0.3960878,-0.50535 1.057602,-0.72235 1.6760616,-0.54979 0.6184669,0.17255 1.0674318,0.73341 1.3352208,1.26428"
 | 
			
		||||
       id="path941"
 | 
			
		||||
       inkscape:connector-curvature="0"
 | 
			
		||||
       sodipodi:nodetypes="csscc" />
 | 
			
		||||
    <path
 | 
			
		||||
       style="opacity:1;fill:#729fcf;fill-opacity:1;fill-rule:nonzero;stroke:#729fcf;stroke-width:0.52916664;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill"
 | 
			
		||||
       d="M 4.1469878,294.08929 3.0515179,293.89345"
 | 
			
		||||
       id="path943"
 | 
			
		||||
       inkscape:connector-curvature="0"
 | 
			
		||||
       sodipodi:nodetypes="cc" />
 | 
			
		||||
    <path
 | 
			
		||||
       sodipodi:nodetypes="cc"
 | 
			
		||||
       inkscape:connector-curvature="0"
 | 
			
		||||
       id="path948"
 | 
			
		||||
       d="m 4.2827903,292.91012 -0.1358025,1.17917"
 | 
			
		||||
       style="opacity:1;fill:#729fcf;fill-opacity:1;fill-rule:nonzero;stroke:#729fcf;stroke-width:0.52916664;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill" />
 | 
			
		||||
  </g>
 | 
			
		||||
</svg>
 | 
			
		||||
| 
		 After Width: | Height: | Size: 3.2 KiB  | 
							
								
								
									
										14
									
								
								src/home.pug
									
										
									
									
									
								
							
							
						
						| 
						 | 
				
			
			@ -33,19 +33,15 @@ doctype html
 | 
			
		|||
html
 | 
			
		||||
  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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,366 +0,0 @@
 | 
			
		|||
import {ElemJS, ejs} from $to_relative "/js/basic.js"
 | 
			
		||||
import {Subscribable} from $to_relative "/js/store/Subscribable.js"
 | 
			
		||||
import {store} from $to_relative "/js/store/store.js"
 | 
			
		||||
import {Anchor} from $to_relative "/js/Anchor.js"
 | 
			
		||||
import * as lsm from $to_relative "/js/lsm.js"
 | 
			
		||||
import {resolveMxc} from $to_relative "/js/functions.js"
 | 
			
		||||
 | 
			
		||||
const dateFormatter = Intl.DateTimeFormat("default", {hour: "numeric", minute: "numeric", day: "numeric", month: "short", year: "numeric"})
 | 
			
		||||
 | 
			
		||||
let sentIndex = 0
 | 
			
		||||
 | 
			
		||||
function getTxnId() {
 | 
			
		||||
	return Date.now() + (sentIndex++)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function eventSearch(list, event, min = 0, max = -1) {
 | 
			
		||||
	if (list.length === 0) return {success: false, i: 0}
 | 
			
		||||
 | 
			
		||||
	if (max === -1) max = list.length - 1
 | 
			
		||||
	let mid = Math.floor((max + min) / 2)
 | 
			
		||||
	// success condition
 | 
			
		||||
	if (list[mid] && list[mid].data.event_id === event.data.event_id) return {success: true, i: mid}
 | 
			
		||||
	// failed condition
 | 
			
		||||
	if (min >= max) {
 | 
			
		||||
		while (mid !== -1 && (!list[mid] || list[mid].data.origin_server_ts > event.data.origin_server_ts)) mid--
 | 
			
		||||
		return {
 | 
			
		||||
			success: false,
 | 
			
		||||
			i: mid + 1
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	// recurse (below)
 | 
			
		||||
	if (list[mid].data.origin_server_ts > event.data.origin_server_ts) return eventSearch(list, event, min, mid-1)
 | 
			
		||||
	// recurse (above)
 | 
			
		||||
	else return eventSearch(list, event, mid+1, max)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class Event extends ElemJS {
 | 
			
		||||
	constructor(data) {
 | 
			
		||||
		super("div")
 | 
			
		||||
		this.class("c-message")
 | 
			
		||||
		this.data = null
 | 
			
		||||
		this.group = null
 | 
			
		||||
		this.editedAt = null
 | 
			
		||||
		this.update(data)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// predicates
 | 
			
		||||
 | 
			
		||||
	canGroup() {
 | 
			
		||||
		return this.data.type === "m.room.message"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// operations
 | 
			
		||||
 | 
			
		||||
	setGroup(group) {
 | 
			
		||||
		this.group = group
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	setEdited(time) {
 | 
			
		||||
		this.editedAt = time
 | 
			
		||||
		this.render()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	update(data) {
 | 
			
		||||
		this.data = data
 | 
			
		||||
		this.render()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	removeEvent() {
 | 
			
		||||
		if (this.group) this.group.removeEvent(this)
 | 
			
		||||
		else this.remove()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		this.element.classList[this.data.pending ? "add" : "remove"]("c-message--pending")
 | 
			
		||||
		if (this.data.type === "m.room.message") {
 | 
			
		||||
			this.text(this.data.content.body)
 | 
			
		||||
		} else if (this.data.type === "m.room.member") {
 | 
			
		||||
			if (this.data.content.membership === "join") {
 | 
			
		||||
				this.child(ejs("i").text("joined the room"))
 | 
			
		||||
			} else {
 | 
			
		||||
				this.child(ejs("i").text("left the room"))
 | 
			
		||||
			}
 | 
			
		||||
		} else {
 | 
			
		||||
			this.child(ejs("i").text(`Unsupported event type ${this.data.type}`))
 | 
			
		||||
		}
 | 
			
		||||
		if (this.editedAt) {
 | 
			
		||||
			this.child(ejs("span").class("c-message__edited").text("(edited)").attribute("title", "at " + dateFormatter.format(this.editedAt)))
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class Sender {
 | 
			
		||||
	constructor(roomID, mxid) {
 | 
			
		||||
		this.sender = store.rooms.get(roomID).value().members.get(mxid)
 | 
			
		||||
		this.sender.subscribe("changeSelf", this.update.bind(this))
 | 
			
		||||
		this.name = new ElemJS("div").class("c-message-group__name")
 | 
			
		||||
		this.avatar = new ElemJS("div").class("c-message-group__avatar")
 | 
			
		||||
		this.displayingGoodData = false
 | 
			
		||||
		this.update()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	update() {
 | 
			
		||||
		if (this.sender.exists()) {
 | 
			
		||||
			// name
 | 
			
		||||
			if (this.sender.value().content.displayname) {
 | 
			
		||||
				this.name.text(this.sender.value().content.displayname)
 | 
			
		||||
				this.displayingGoodData = true
 | 
			
		||||
			} else if (!this.displayingGoodData) {
 | 
			
		||||
				this.name.text(this.sender.value().state_key)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			// avatar
 | 
			
		||||
			this.avatar.clearChildren()
 | 
			
		||||
			if (this.sender.value().content.avatar_url) {
 | 
			
		||||
				this.avatar.child(
 | 
			
		||||
					ejs("img").class("c-message-group__icon").attribute("src", resolveMxc(this.sender.value().content.avatar_url, 96, "crop"))
 | 
			
		||||
				)
 | 
			
		||||
			} else {
 | 
			
		||||
				this.avatar.child(
 | 
			
		||||
					ejs("div").class("c-message-group__icon", "c-message-group__icon--no-icon")
 | 
			
		||||
				)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class EventGroup extends ElemJS {
 | 
			
		||||
	constructor(reactive, list) {
 | 
			
		||||
		super("div")
 | 
			
		||||
		this.class("c-message-group")
 | 
			
		||||
		this.reactive = reactive
 | 
			
		||||
		this.list = list
 | 
			
		||||
		this.data = {
 | 
			
		||||
			sender: list[0].data.sender,
 | 
			
		||||
			origin_server_ts: list[0].data.origin_server_ts
 | 
			
		||||
		}
 | 
			
		||||
		this.sender = new Sender(this.reactive.id, this.data.sender)
 | 
			
		||||
		this.child(
 | 
			
		||||
			this.sender.avatar,
 | 
			
		||||
			this.messages = ejs("div").class("c-message-group__messages").child(
 | 
			
		||||
				ejs("div").class("c-message-group__intro").child(
 | 
			
		||||
					this.sender.name,
 | 
			
		||||
					ejs("div").class("c-message-group__date").text(dateFormatter.format(this.data.origin_server_ts))
 | 
			
		||||
				),
 | 
			
		||||
				...this.list
 | 
			
		||||
			)
 | 
			
		||||
		)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	addEvent(event) {
 | 
			
		||||
		const index = eventSearch(this.list, event).i
 | 
			
		||||
		event.setGroup(this)
 | 
			
		||||
		this.list.splice(index, 0, event)
 | 
			
		||||
		this.messages.childAt(index + 1, event)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	removeEvent(event) {
 | 
			
		||||
		const search = eventSearch(this.list, event)
 | 
			
		||||
		if (!search.success) throw new Error(`Event ${event.data.event_id} not found in this group`)
 | 
			
		||||
		const index = search.i
 | 
			
		||||
		// actually remove the event
 | 
			
		||||
		this.list.splice(index, 1)
 | 
			
		||||
		event.remove() // should get everything else
 | 
			
		||||
		if (this.list.length === 0) this.reactive.removeGroup(this)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class ReactiveTimeline extends ElemJS {
 | 
			
		||||
	constructor(id, list) {
 | 
			
		||||
		super("div")
 | 
			
		||||
		this.class("c-event-groups")
 | 
			
		||||
		this.id = id
 | 
			
		||||
		this.list = list
 | 
			
		||||
		this.render()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	addEvent(event) {
 | 
			
		||||
		const search = eventSearch(this.list, event)
 | 
			
		||||
		// console.log(search, this.list.map(l => l.data.sender), event.data)
 | 
			
		||||
		if (!search.success && search.i >= 1) this.tryAddGroups(event, [search.i-1, search.i])
 | 
			
		||||
		else this.tryAddGroups(event, [search.i])
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	tryAddGroups(event, indices) {
 | 
			
		||||
		const success = indices.some(i => {
 | 
			
		||||
			if (!this.list[i]) {
 | 
			
		||||
				// if (printed++ < 100) console.log("tryadd success, created group")
 | 
			
		||||
				const group = new EventGroup(this, [event])
 | 
			
		||||
				this.list.splice(i, 0, group)
 | 
			
		||||
				this.childAt(i, group)
 | 
			
		||||
				event.setGroup(group)
 | 
			
		||||
				return true
 | 
			
		||||
			} else if (this.list[i] && this.list[i].data.sender === event.data.sender) {
 | 
			
		||||
				// if (printed++ < 100) console.log("tryadd success, using existing group")
 | 
			
		||||
				this.list[i].addEvent(event)
 | 
			
		||||
				return true
 | 
			
		||||
			}
 | 
			
		||||
		})
 | 
			
		||||
		if (!success) console.log("tryadd failure", indices, this.list.map(l => l.data.sender), event.data)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	removeGroup(group) {
 | 
			
		||||
		const index = this.list.indexOf(group)
 | 
			
		||||
		this.list.splice(index, 1)
 | 
			
		||||
		group.remove() // should get everything else
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		this.clearChildren()
 | 
			
		||||
		this.list.forEach(group => this.child(group))
 | 
			
		||||
		this.anchor = new Anchor()
 | 
			
		||||
		this.child(this.anchor)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class Timeline extends Subscribable {
 | 
			
		||||
	constructor(room) {
 | 
			
		||||
		super()
 | 
			
		||||
		Object.assign(this.events, {
 | 
			
		||||
			beforeChange: [],
 | 
			
		||||
			afterChange: []
 | 
			
		||||
		})
 | 
			
		||||
		Object.assign(this.eventDeps, {
 | 
			
		||||
			beforeChange: [],
 | 
			
		||||
			afterChange: []
 | 
			
		||||
		})
 | 
			
		||||
		this.room = room
 | 
			
		||||
		this.id = this.room.id
 | 
			
		||||
		this.list = []
 | 
			
		||||
		this.map = new Map()
 | 
			
		||||
		this.reactiveTimeline = new ReactiveTimeline(this.id, [])
 | 
			
		||||
		this.latest = 0
 | 
			
		||||
		this.pending = new Set()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	updateStateEvents(events) {
 | 
			
		||||
		for (const eventData of events) {
 | 
			
		||||
			let id = eventData.event_id
 | 
			
		||||
			if (eventData.type === "m.room.member") {
 | 
			
		||||
				// update members
 | 
			
		||||
				if (eventData.membership !== "leave") {
 | 
			
		||||
					this.room.members.get(eventData.state_key).set(eventData)
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	updateEvents(events) {
 | 
			
		||||
		this.broadcast("beforeChange")
 | 
			
		||||
		// handle state events
 | 
			
		||||
		this.updateStateEvents(events)
 | 
			
		||||
		for (const eventData of events) {
 | 
			
		||||
			// set variables
 | 
			
		||||
			this.latest = Math.max(this.latest, eventData.origin_server_ts)
 | 
			
		||||
			let id = eventData.event_id
 | 
			
		||||
			// handle local echoes
 | 
			
		||||
			if (eventData.sender === lsm.get("mx_user_id") && eventData.content && this.pending.has(eventData.content["chat.carbon.message.pending_id"])) {
 | 
			
		||||
				const target = this.map.get(eventData.content["chat.carbon.message.pending_id"])
 | 
			
		||||
				this.map.set(id, target)
 | 
			
		||||
				this.map.delete(eventData.content["chat.carbon.message.pending_id"])
 | 
			
		||||
			}
 | 
			
		||||
			// handle timeline events
 | 
			
		||||
			if (this.map.has(id)) {
 | 
			
		||||
				// update existing event
 | 
			
		||||
				this.map.get(id).update(eventData)
 | 
			
		||||
			} else {
 | 
			
		||||
				// skip displaying events that we don't know how to
 | 
			
		||||
				if (eventData.type === "m.reaction") {
 | 
			
		||||
					continue
 | 
			
		||||
				}
 | 
			
		||||
				// skip redacted events
 | 
			
		||||
				if (eventData.unsigned && eventData.unsigned.redacted_by) {
 | 
			
		||||
					continue
 | 
			
		||||
				}
 | 
			
		||||
				// handle redactions
 | 
			
		||||
				if (eventData.type === "m.room.redaction") {
 | 
			
		||||
					if (this.map.has(eventData.redacts)) this.map.get(eventData.redacts).removeEvent()
 | 
			
		||||
					continue
 | 
			
		||||
				}
 | 
			
		||||
				// handle edits
 | 
			
		||||
				if (eventData.type === "m.room.message" && eventData.content["m.relates_to"] && eventData.content["m.relates_to"].rel_type === "m.replace") {
 | 
			
		||||
					const replaces = eventData.content["m.relates_to"].event_id
 | 
			
		||||
					if (this.map.has(replaces)) {
 | 
			
		||||
						const event = this.map.get(replaces)
 | 
			
		||||
						event.data.content = eventData.content["m.new_content"]
 | 
			
		||||
						event.setEdited(eventData.origin_server_ts)
 | 
			
		||||
						event.update(event.data)
 | 
			
		||||
						continue
 | 
			
		||||
					} else {
 | 
			
		||||
						// uhhhhhhh
 | 
			
		||||
						console.error(`want to replace event ${replaces} with ${eventData.id} but replaced event not found`)
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
				// add new event
 | 
			
		||||
				const event = new Event(eventData)
 | 
			
		||||
				this.map.set(id, event)
 | 
			
		||||
				this.reactiveTimeline.addEvent(event)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		this.broadcast("afterChange")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	removeEvent(id) {
 | 
			
		||||
		if (!this.map.has(id)) throw new Error(`Tried to delete event ID ${id} which does not exist`)
 | 
			
		||||
		this.map.get(id).removeEvent()
 | 
			
		||||
		this.map.delete(id)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	getTimeline() {
 | 
			
		||||
		return this.reactiveTimeline
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	send(body) {
 | 
			
		||||
		const tx = getTxnId()
 | 
			
		||||
		const id = `pending$${tx}`
 | 
			
		||||
		this.pending.add(id)
 | 
			
		||||
		const content = {
 | 
			
		||||
			msgtype: "m.text",
 | 
			
		||||
			body,
 | 
			
		||||
			"chat.carbon.message.pending_id": id
 | 
			
		||||
		}
 | 
			
		||||
		const fakeEvent = {
 | 
			
		||||
			type: "m.room.message",
 | 
			
		||||
			origin_server_ts: Date.now(),
 | 
			
		||||
			event_id: id,
 | 
			
		||||
			sender: lsm.get("mx_user_id"),
 | 
			
		||||
			content,
 | 
			
		||||
			pending: true
 | 
			
		||||
		}
 | 
			
		||||
		this.updateEvents([fakeEvent])
 | 
			
		||||
		return fetch(`${lsm.get("domain")}/_matrix/client/r0/rooms/${this.id}/send/m.room.message/${tx}?access_token=${lsm.get("access_token")}`, {
 | 
			
		||||
			method: "PUT",
 | 
			
		||||
			body: JSON.stringify(content),
 | 
			
		||||
			headers: {
 | 
			
		||||
				"Content-Type": "application/json"
 | 
			
		||||
			}
 | 
			
		||||
		})/*.then(() => {
 | 
			
		||||
			const subscription = () => {
 | 
			
		||||
				this.removeEvent(id)
 | 
			
		||||
				this.unsubscribe("afterChange", subscription)
 | 
			
		||||
			}
 | 
			
		||||
			this.subscribe("afterChange", subscription)
 | 
			
		||||
		})*/
 | 
			
		||||
	}
 | 
			
		||||
/*
 | 
			
		||||
	getGroupedEvents() {
 | 
			
		||||
		let currentSender = Symbol("N/A")
 | 
			
		||||
		let groups = []
 | 
			
		||||
		let currentGroup = []
 | 
			
		||||
		for (const event of this.list) {
 | 
			
		||||
			if (event.sender === currentSender) {
 | 
			
		||||
				currentGroup.push(event)
 | 
			
		||||
			} else {
 | 
			
		||||
				if (currentGroup.length) groups.push(currentGroup)
 | 
			
		||||
				currentGroup = [event]
 | 
			
		||||
				currentSender = event.sender
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		if (currentGroup.length) groups.push(currentGroup)
 | 
			
		||||
		return groups
 | 
			
		||||
	}
 | 
			
		||||
	*/
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export {Timeline}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,4 +1,4 @@
 | 
			
		|||
import {ElemJS} from $to_relative "/js/basic.js"
 | 
			
		||||
const {ElemJS} = require("./basic.js")
 | 
			
		||||
 | 
			
		||||
class Anchor extends ElemJS {
 | 
			
		||||
	constructor() {
 | 
			
		||||
| 
						 | 
				
			
			@ -12,4 +12,4 @@ class Anchor extends ElemJS {
 | 
			
		|||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export {Anchor}
 | 
			
		||||
module.exports = {Anchor}
 | 
			
		||||
| 
						 | 
				
			
			@ -19,12 +19,12 @@ const qa = s => document.querySelectorAll(s);
 | 
			
		|||
 */
 | 
			
		||||
class ElemJS {
 | 
			
		||||
	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}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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)
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,3 @@
 | 
			
		|||
const dateFormatter = Intl.DateTimeFormat("default", {hour: "numeric", minute: "numeric", day: "numeric", month: "short", year: "numeric"})
 | 
			
		||||
 | 
			
		||||
module.exports = {dateFormatter}
 | 
			
		||||
							
								
								
									
										67
									
								
								src/js/events/call.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,67 @@
 | 
			
		|||
const {UngroupableEvent} = require("./event")
 | 
			
		||||
const {ejs} = require("../basic")
 | 
			
		||||
const lsm = require("../lsm")
 | 
			
		||||
const {extractDisplayName, resolveMxc, extractLocalpart} = require("../functions")
 | 
			
		||||
 | 
			
		||||
class CallEvent extends UngroupableEvent {
 | 
			
		||||
	constructor(data) {
 | 
			
		||||
		super(data)
 | 
			
		||||
		this.class("c-message-event")
 | 
			
		||||
		this.senderName = extractLocalpart(this.data.sender)
 | 
			
		||||
		this.render()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	renderInner(iconURL, elements) {
 | 
			
		||||
		this.clearChildren()
 | 
			
		||||
		this.child(
 | 
			
		||||
			ejs("div").class("c-message-event__inner").child(
 | 
			
		||||
				iconURL ? ejs("img").class("c-message-event__icon").attribute("width", "20").attribute("height", "20").attribute("src", iconURL) : "",
 | 
			
		||||
				...elements
 | 
			
		||||
			)
 | 
			
		||||
		)
 | 
			
		||||
		super.render()
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class CallInviteEvent extends CallEvent {
 | 
			
		||||
	static canRender(eventData) {
 | 
			
		||||
		return eventData.type === "m.call.invite"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		const icon = this.data.sender === lsm.get("mx_user_id") ? "static/call-out.svg" : "static/call-in.svg"
 | 
			
		||||
		this.renderInner(icon, [
 | 
			
		||||
			this.senderName,
 | 
			
		||||
			" started a VOIP call, but Carbon doesn't support VOIP calls"
 | 
			
		||||
		])
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class CallAnswerEvent extends CallEvent {
 | 
			
		||||
	static canRender(eventData) {
 | 
			
		||||
		return eventData.type === "m.call.answer"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		this.renderInner("static/call-accepted.svg", [
 | 
			
		||||
			this.senderName,
 | 
			
		||||
			" answered the call"
 | 
			
		||||
		])
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class CallHangupEvent extends CallEvent {
 | 
			
		||||
	static canRender(eventData) {
 | 
			
		||||
		return eventData.type === "m.call.hangup"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		const reason = this.data.content.reason === "invite_timeout" ? "missed the call" : "hung up the call"
 | 
			
		||||
		this.renderInner("static/call-rejected.svg", [
 | 
			
		||||
			this.senderName,
 | 
			
		||||
			" " + reason
 | 
			
		||||
		])
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = [CallInviteEvent, CallAnswerEvent, CallHangupEvent]
 | 
			
		||||
							
								
								
									
										36
									
								
								src/js/events/components.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,36 @@
 | 
			
		|||
const {ElemJS} = require("../basic")
 | 
			
		||||
const {lazyLoad} = require("../lazy-load-module")
 | 
			
		||||
 | 
			
		||||
class HighlightedCode extends ElemJS {
 | 
			
		||||
	constructor(element) {
 | 
			
		||||
		super(element)
 | 
			
		||||
		if (this.element.tagName === "PRE" && this.element.children.length === 1 && this.element.children[0].tagName === "CODE") {
 | 
			
		||||
			// we shouldn't nest <code> inside <pre>. put the text in <pre> directly.
 | 
			
		||||
			const code = this.element.children[0]
 | 
			
		||||
			this.clearChildren()
 | 
			
		||||
			while (code.firstChild) {
 | 
			
		||||
				this.element.appendChild(code.firstChild)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		let shouldHighlight = (
 | 
			
		||||
			// if there are child _elements_, it's already formatted, we shouldn't mess that up
 | 
			
		||||
			this.element.children.length === 0
 | 
			
		||||
			/*
 | 
			
		||||
			  no need to highlight very short code blocks:
 | 
			
		||||
			  - content inside might not be code, some users still use code blocks
 | 
			
		||||
			  for plaintext quotes
 | 
			
		||||
			  - language detection will almost certainly be incorrect
 | 
			
		||||
			  - even if it's code and the language is detected, the user will
 | 
			
		||||
			  be able to mentally format small amounts of code themselves
 | 
			
		||||
 | 
			
		||||
			  feel free to change the threshold number
 | 
			
		||||
			*/
 | 
			
		||||
				&& this.element.textContent.length > 80
 | 
			
		||||
		)
 | 
			
		||||
		if (shouldHighlight) {
 | 
			
		||||
			lazyLoad("https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@10/build/highlight.min.js").then(hljs => hljs.highlightBlock(this.element))
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = {HighlightedCode}
 | 
			
		||||
							
								
								
									
										18
									
								
								src/js/events/encrypted.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,18 @@
 | 
			
		|||
const {GroupableEvent} = require("./event")
 | 
			
		||||
const {ejs} = require("../basic")
 | 
			
		||||
 | 
			
		||||
class EncryptedMessage extends GroupableEvent {
 | 
			
		||||
	render() {
 | 
			
		||||
		this.clearChildren()
 | 
			
		||||
		this.child(
 | 
			
		||||
			ejs("i").text("Carbon cannot render encrypted messages yet")
 | 
			
		||||
		)
 | 
			
		||||
		super.render()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	static canRender(eventData) {
 | 
			
		||||
		return eventData.type === "m.room.encrypted"
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = [EncryptedMessage]
 | 
			
		||||
							
								
								
									
										72
									
								
								src/js/events/event.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,72 @@
 | 
			
		|||
const {ElemJS, ejs} = require("../basic")
 | 
			
		||||
const {dateFormatter} = require("../date-formatter")
 | 
			
		||||
const {SubscribeSet} = require("../store/subscribe_set.js")
 | 
			
		||||
 | 
			
		||||
class MatrixEvent extends ElemJS {
 | 
			
		||||
	constructor(data) {
 | 
			
		||||
		super("div")
 | 
			
		||||
		this.data = null
 | 
			
		||||
		this.group = null
 | 
			
		||||
		this.editedAt = null
 | 
			
		||||
		this.readBy = new SubscribeSet()
 | 
			
		||||
		this.update(data)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// predicates
 | 
			
		||||
 | 
			
		||||
	canGroup() {
 | 
			
		||||
		return false
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// operations
 | 
			
		||||
 | 
			
		||||
	setGroup(group) {
 | 
			
		||||
		this.group = group
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	setEdited(time) {
 | 
			
		||||
		this.editedAt = time
 | 
			
		||||
		this.render()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	update(data) {
 | 
			
		||||
		this.data = data
 | 
			
		||||
		this.render()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	removeEvent() {
 | 
			
		||||
		if (this.group) this.group.removeEvent(this)
 | 
			
		||||
		else this.remove()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		this.element.classList[this.data.pending ? "add" : "remove"]("c-message--pending")
 | 
			
		||||
		if (this.editedAt) {
 | 
			
		||||
			this.child(ejs("span").class("c-message__edited").text("(edited)").attribute("title", "at " + dateFormatter.format(this.editedAt)))
 | 
			
		||||
		}
 | 
			
		||||
		return this
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	static canRender(eventData) {
 | 
			
		||||
		return false
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class GroupableEvent extends MatrixEvent {
 | 
			
		||||
	constructor(data) {
 | 
			
		||||
		super(data)
 | 
			
		||||
		this.class("c-message")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	canGroup() {
 | 
			
		||||
		return true
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class UngroupableEvent extends MatrixEvent {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
	GroupableEvent,
 | 
			
		||||
	UngroupableEvent
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										18
									
								
								src/js/events/hidden.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,18 @@
 | 
			
		|||
const {UngroupableEvent} = require("./event")
 | 
			
		||||
 | 
			
		||||
class HiddenEvent extends UngroupableEvent {
 | 
			
		||||
	constructor(data) {
 | 
			
		||||
		super(data)
 | 
			
		||||
		this.class("c-hidden-event")
 | 
			
		||||
		this.clearChildren()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	static canRender(eventData) {
 | 
			
		||||
		return ["m.reaction", "m.call.candidates"].includes(eventData.type)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = [HiddenEvent]
 | 
			
		||||
							
								
								
									
										48
									
								
								src/js/events/image.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,48 @@
 | 
			
		|||
const {ejs, ElemJS} = require("../basic")
 | 
			
		||||
const {resolveMxc} = require("../functions")
 | 
			
		||||
const {GroupableEvent} = require("./event")
 | 
			
		||||
 | 
			
		||||
class Image extends GroupableEvent {
 | 
			
		||||
	render() {
 | 
			
		||||
		this.clearChildren()
 | 
			
		||||
		this.class("c-message--media")
 | 
			
		||||
		const image = (
 | 
			
		||||
			ejs("img")
 | 
			
		||||
				.class("c-message__image")
 | 
			
		||||
				.attribute("src", resolveMxc(this.data.content.url))
 | 
			
		||||
		)
 | 
			
		||||
		const info = this.data.content.info
 | 
			
		||||
		if (info && info.w && info.h) {
 | 
			
		||||
			image.attribute("width", info.w)
 | 
			
		||||
			image.attribute("height", info.h)
 | 
			
		||||
		}
 | 
			
		||||
		const wrapper = ejs("div").class("c-media__wrapper").child(
 | 
			
		||||
			image
 | 
			
		||||
		)
 | 
			
		||||
		if (this.data.content.body && this.data.content.body.startsWith("SPOILER")) {
 | 
			
		||||
			wrapper.attribute("tabindex", 0)
 | 
			
		||||
			wrapper.class("c-media--spoiler")
 | 
			
		||||
			const wall = ejs("div").class("c-media__spoiler").text("Spoiler")
 | 
			
		||||
			wrapper.child(wall)
 | 
			
		||||
			const toggle = () => {
 | 
			
		||||
				wrapper.element.classList.toggle("c-media--shown")
 | 
			
		||||
			}
 | 
			
		||||
			wrapper.on("click", toggle)
 | 
			
		||||
			wrapper.on("keydown", event => {
 | 
			
		||||
				if (event.key === "Enter") toggle()
 | 
			
		||||
			})
 | 
			
		||||
		}
 | 
			
		||||
		this.child(wrapper)
 | 
			
		||||
		super.render()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	static canRender(event) {
 | 
			
		||||
		return event.type === "m.room.message" && event.content.msgtype === "m.image"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	canGroup() {
 | 
			
		||||
		return true
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = [Image]
 | 
			
		||||
							
								
								
									
										127
									
								
								src/js/events/membership.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,127 @@
 | 
			
		|||
const {UngroupableEvent} = require("./event")
 | 
			
		||||
const {ejs} = require("../basic")
 | 
			
		||||
const {extractDisplayName, resolveMxc, extractLocalpart} = require("../functions")
 | 
			
		||||
 | 
			
		||||
class MembershipEvent extends UngroupableEvent {
 | 
			
		||||
	constructor(data) {
 | 
			
		||||
		super(data)
 | 
			
		||||
		this.class("c-message-event")
 | 
			
		||||
		this.senderName = extractDisplayName(data)
 | 
			
		||||
		if (data.content.avatar_url) {
 | 
			
		||||
			this.smallAvatar = ejs("img")
 | 
			
		||||
				.attribute("width", "32")
 | 
			
		||||
				.attribute("height", "32")
 | 
			
		||||
				.attribute("src", resolveMxc(data.content.avatar_url, 32, "crop"))
 | 
			
		||||
				.class("c-message-event__avatar")
 | 
			
		||||
		} else {
 | 
			
		||||
			this.smallAvatar = ""
 | 
			
		||||
		}
 | 
			
		||||
		this.render()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	static canRender(eventData) {
 | 
			
		||||
		return eventData.type === "m.room.member"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	renderInner(iconURL, elements) {
 | 
			
		||||
		this.clearChildren()
 | 
			
		||||
		this.child(
 | 
			
		||||
			ejs("div").class("c-message-event__inner").child(
 | 
			
		||||
				iconURL ? ejs("img").class("c-message-event__icon").attribute("width", "20").attribute("height", "20").attribute("src", iconURL) : "",
 | 
			
		||||
				...elements
 | 
			
		||||
			)
 | 
			
		||||
		)
 | 
			
		||||
		super.render()
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class JoinedEvent extends MembershipEvent {
 | 
			
		||||
	static canRender(eventData) {
 | 
			
		||||
		return super.canRender(eventData) && eventData.content.membership === "join"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		const changes = []
 | 
			
		||||
		const prev = this.data.unsigned.prev_content
 | 
			
		||||
		if (prev && prev.membership === "join") {
 | 
			
		||||
			if (prev.avatar_url !== this.data.content.avatar_url) {
 | 
			
		||||
				changes.push("changed their avatar")
 | 
			
		||||
			}
 | 
			
		||||
			if (prev.displayname !== this.data.content.displayname) {
 | 
			
		||||
				changes.push(`changed their display name (was ${this.data.unsigned.prev_content.displayname})`)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		let message
 | 
			
		||||
		let iconURL
 | 
			
		||||
		if (changes.length) {
 | 
			
		||||
			message = " " + changes.join(", ")
 | 
			
		||||
			iconURL = "static/profile-event.svg"
 | 
			
		||||
		} else {
 | 
			
		||||
			message = " joined the room"
 | 
			
		||||
			iconURL = "static/join-event.svg"
 | 
			
		||||
		}
 | 
			
		||||
		this.renderInner(iconURL, [
 | 
			
		||||
			this.smallAvatar,
 | 
			
		||||
			this.senderName,
 | 
			
		||||
			message
 | 
			
		||||
		])
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class InvitedEvent extends MembershipEvent {
 | 
			
		||||
	static canRender(eventData) {
 | 
			
		||||
		return super.canRender(eventData) && eventData.content.membership === "invite"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		this.renderInner("static/invite-event.svg", [
 | 
			
		||||
			this.smallAvatar,
 | 
			
		||||
			`${extractLocalpart(this.data.sender)} invited ${this.data.state_key}` // full mxid for clarity
 | 
			
		||||
		])
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class LeaveEvent extends MembershipEvent {
 | 
			
		||||
	static canRender(eventData) {
 | 
			
		||||
		return super.canRender(eventData) && eventData.content.membership === "leave"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		this.renderInner("static/leave-event.svg", [
 | 
			
		||||
			this.smallAvatar,
 | 
			
		||||
			this.senderName,
 | 
			
		||||
			" left the room"
 | 
			
		||||
		])
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class BanEvent extends MembershipEvent {
 | 
			
		||||
	static canRender(eventData) {
 | 
			
		||||
		return super.canRender(eventData) && eventData.content.membership === "ban"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		let message =
 | 
			
		||||
			 ` left (banned by ${this.data.sender}`
 | 
			
		||||
			 + (this.data.content.reason ? `, reason: ${this.data.content.reason}` : "")
 | 
			
		||||
			 + ")"
 | 
			
		||||
		this.renderInner("static/leave-event.svg", [
 | 
			
		||||
			this.smallAvatar,
 | 
			
		||||
			this.senderName,
 | 
			
		||||
			message
 | 
			
		||||
		])
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class UnknownMembership extends MembershipEvent {
 | 
			
		||||
	render() {
 | 
			
		||||
		this.renderInner("", [
 | 
			
		||||
			this.smallAvatar,
 | 
			
		||||
			this.senderName,
 | 
			
		||||
			ejs("i").text(" unknown membership event")
 | 
			
		||||
		])
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = [JoinedEvent, InvitedEvent, LeaveEvent, BanEvent, UnknownMembership]
 | 
			
		||||
							
								
								
									
										164
									
								
								src/js/events/message.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,164 @@
 | 
			
		|||
const {ejs, ElemJS} = require("../basic")
 | 
			
		||||
const {HighlightedCode} = require("./components")
 | 
			
		||||
const DOMPurify = require("dompurify")
 | 
			
		||||
const {resolveMxc} = require("../functions")
 | 
			
		||||
const {GroupableEvent} = require("./event")
 | 
			
		||||
 | 
			
		||||
const purifier = DOMPurify()
 | 
			
		||||
 | 
			
		||||
purifier.addHook("uponSanitizeAttribute", (node, hookevent, config) => {
 | 
			
		||||
	// If purifier already rejected an attribute there is no point in checking it
 | 
			
		||||
	if (hookevent.keepAttr === false) return;
 | 
			
		||||
 | 
			
		||||
	const allowedElementAttributes = {
 | 
			
		||||
		"FONT": ["data-mx-bg-color", "data-mx-color", "color"],
 | 
			
		||||
		"SPAN": ["data-mx-bg-color", "data-mx-color", "data-mx-spoiler"],
 | 
			
		||||
		"A": ["name", "target", "href"],
 | 
			
		||||
		"IMG": ["width", "height", "alt", "title", "src", "data-mx-emoticon"],
 | 
			
		||||
		"OL": ["start"],
 | 
			
		||||
		"CODE": ["class"],
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	const allowedAttributes = allowedElementAttributes[node.tagName] || []
 | 
			
		||||
	hookevent.keepAttr = allowedAttributes.indexOf(hookevent.attrName) > -1
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
purifier.addHook("uponSanitizeElement", (node, hookevent, config) => {
 | 
			
		||||
	// Remove bad classes from our code element
 | 
			
		||||
	if (node.tagName === "CODE") {
 | 
			
		||||
		node.classList.forEach(c => {
 | 
			
		||||
			if (!c.startsWith("language-")) {
 | 
			
		||||
				node.classList.remove(c)
 | 
			
		||||
			}
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
	if (node.tagName === "A") {
 | 
			
		||||
		node.setAttribute("rel", "noopener") // prevent the opening page from accessing carbon
 | 
			
		||||
		node.setAttribute("target", "_blank") // open in a new tab instead of replacing carbon
 | 
			
		||||
	}
 | 
			
		||||
	return node
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
function cleanHTML(html) {
 | 
			
		||||
	const config = {
 | 
			
		||||
		ALLOWED_TAGS: [
 | 
			
		||||
			"font", "del", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "p",
 | 
			
		||||
			"a", "ul", "ol", "sup", "sub", "li", "b", "i", "u", "strong", "em",
 | 
			
		||||
			"strike", "code", "hr", "br", "div", "table", "thead", "tbody", "tr",
 | 
			
		||||
			"th", "td", "caption", "pre", "span", "img",
 | 
			
		||||
			// matrix tags
 | 
			
		||||
			"mx-reply"
 | 
			
		||||
		],
 | 
			
		||||
 | 
			
		||||
		// In case we mess up in the uponSanitizeAttribute hook
 | 
			
		||||
		ALLOWED_ATTR: [
 | 
			
		||||
			"color", "name", "target", "href", "width", "height", "alt", "title",
 | 
			
		||||
			"src", "start", "class", "noreferrer", "noopener",
 | 
			
		||||
			// matrix attrs
 | 
			
		||||
			"data-mx-emoticon", "data-mx-bg-color", "data-mx-color", "data-mx-spoiler"
 | 
			
		||||
		],
 | 
			
		||||
 | 
			
		||||
		// Return a DOM fragment instead of a string, avoids potential future mutation XSS
 | 
			
		||||
		// should also be faster than the browser parsing HTML twice
 | 
			
		||||
		// https://research.securitum.com/mutation-xss-via-mathml-mutation-dompurify-2-0-17-bypass/
 | 
			
		||||
		RETURN_DOM_FRAGMENT: true,
 | 
			
		||||
		RETURN_DOM_IMPORT: true
 | 
			
		||||
	}
 | 
			
		||||
	return purifier.sanitize(html, config)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Here we put all the processing of the messages that isn't as likely to potentially lead to security issues
 | 
			
		||||
function postProcessElements(element) {
 | 
			
		||||
	element.querySelectorAll("pre").forEach(n => {
 | 
			
		||||
		new HighlightedCode(n)
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	element.querySelectorAll("img").forEach(n => {
 | 
			
		||||
		let src = n.getAttribute("src")
 | 
			
		||||
		if (src) src = resolveMxc(src)
 | 
			
		||||
		n.setAttribute("src", src)
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	element.querySelectorAll("font, span").forEach(n => {
 | 
			
		||||
		const color = n.getAttribute("data-mx-color") || n.getAttribute("color")
 | 
			
		||||
		const bgColor = n.getAttribute("data-mx-bg-color")
 | 
			
		||||
		if (color) n.style.color = color
 | 
			
		||||
		if (bgColor) n.style.backgroundColor = bgColor
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	element.querySelectorAll("[data-mx-spoiler]").forEach(spoiler => {
 | 
			
		||||
		spoiler.classList.add("mx-spoiler")
 | 
			
		||||
		spoiler.setAttribute("tabindex", 0)
 | 
			
		||||
		function toggle() {
 | 
			
		||||
			spoiler.classList.toggle("mx-spoiler--shown")
 | 
			
		||||
		}
 | 
			
		||||
		spoiler.addEventListener("click", toggle)
 | 
			
		||||
		spoiler.addEventListener("keydown", event => {
 | 
			
		||||
			if (event.key === "Enter") toggle()
 | 
			
		||||
		})
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class HTMLMessage extends GroupableEvent {
 | 
			
		||||
	render() {
 | 
			
		||||
		this.clearChildren()
 | 
			
		||||
 | 
			
		||||
		let html = this.data.content.formatted_body
 | 
			
		||||
 | 
			
		||||
		const fragment = cleanHTML(html)
 | 
			
		||||
		postProcessElements(fragment)
 | 
			
		||||
 | 
			
		||||
		this.child(ejs(fragment))
 | 
			
		||||
 | 
			
		||||
		super.render()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	static canRender(event) {
 | 
			
		||||
		const content = event.content
 | 
			
		||||
		return (
 | 
			
		||||
			event.type === "m.room.message"
 | 
			
		||||
				&& (content.msgtype === "m.text" || content.msgtype === "m.notice")
 | 
			
		||||
				&& content.format === "org.matrix.custom.html"
 | 
			
		||||
				&& content.formatted_body
 | 
			
		||||
		)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function autoLinkText(text) {
 | 
			
		||||
	const fragment = ejs(new DocumentFragment())
 | 
			
		||||
	let lastIndex = 0
 | 
			
		||||
	text.replace(/https?:\/\/(?:[A-Za-z-]+\.)+[A-Za-z]{1,10}(?::[0-9]{1,6})?(?:\/[^ ]*)?/g, (url, index) => {
 | 
			
		||||
		// add text before URL
 | 
			
		||||
		fragment.addText(text.slice(lastIndex, index))
 | 
			
		||||
		// add URL
 | 
			
		||||
		fragment.child(
 | 
			
		||||
			ejs("a")
 | 
			
		||||
				.attribute("target", "_blank")
 | 
			
		||||
				.attribute("noopener", "")
 | 
			
		||||
				.attribute("href", url)
 | 
			
		||||
				.addText(url)
 | 
			
		||||
		)
 | 
			
		||||
		// update state
 | 
			
		||||
		lastIndex = index + url.length
 | 
			
		||||
	})
 | 
			
		||||
	// add final text
 | 
			
		||||
	fragment.addText(text.slice(lastIndex))
 | 
			
		||||
	return fragment
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class TextMessage extends GroupableEvent {
 | 
			
		||||
	render() {
 | 
			
		||||
		this.clearChildren()
 | 
			
		||||
		this.class("c-message--plain")
 | 
			
		||||
		const fragment = autoLinkText(this.data.content.body)
 | 
			
		||||
		this.child(fragment)
 | 
			
		||||
		super.render()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	static canRender(event) {
 | 
			
		||||
		return event.type === "m.room.message"
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = [HTMLMessage, TextMessage]
 | 
			
		||||
							
								
								
									
										24
									
								
								src/js/events/render-event.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,24 @@
 | 
			
		|||
const imageEvent = require("./image")
 | 
			
		||||
const messageEvent = require("./message")
 | 
			
		||||
const encryptedEvent = require("./encrypted")
 | 
			
		||||
const membershipEvent = require("./membership")
 | 
			
		||||
const unknownEvent = require("./unknown")
 | 
			
		||||
const callEvent = require("./call")
 | 
			
		||||
const hiddenEvent = require("./hidden")
 | 
			
		||||
 | 
			
		||||
const events = [
 | 
			
		||||
	...imageEvent,
 | 
			
		||||
	...messageEvent,
 | 
			
		||||
	...encryptedEvent,
 | 
			
		||||
	...membershipEvent,
 | 
			
		||||
	...callEvent,
 | 
			
		||||
	...hiddenEvent,
 | 
			
		||||
	...unknownEvent,
 | 
			
		||||
]
 | 
			
		||||
 | 
			
		||||
function renderEvent(eventData) {
 | 
			
		||||
	const constructor = events.find(e => e.canRender(eventData))
 | 
			
		||||
	return new constructor(eventData)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = {renderEvent}
 | 
			
		||||
							
								
								
									
										19
									
								
								src/js/events/unknown.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,19 @@
 | 
			
		|||
const {GroupableEvent} = require("./event")
 | 
			
		||||
const {ejs} = require("../basic")
 | 
			
		||||
 | 
			
		||||
class UnknownEvent extends GroupableEvent {
 | 
			
		||||
	static canRender() {
 | 
			
		||||
		return true
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		this.clearChildren()
 | 
			
		||||
		this.child(
 | 
			
		||||
			ejs("i").text(`Unknown event of type ${this.data.type}`)
 | 
			
		||||
		)
 | 
			
		||||
		super.render()
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = [UnknownEvent]
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										11
									
								
								src/js/focus.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,11 @@
 | 
			
		|||
document.body.classList.remove("show-focus")
 | 
			
		||||
 | 
			
		||||
document.addEventListener("mousedown", () => {
 | 
			
		||||
	document.body.classList.remove("show-focus")
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
document.addEventListener("keydown", event => {
 | 
			
		||||
	if (event.key === "Tab") {
 | 
			
		||||
		document.body.classList.add("show-focus")
 | 
			
		||||
	}
 | 
			
		||||
})
 | 
			
		||||
| 
						 | 
				
			
			@ -1,7 +1,10 @@
 | 
			
		|||
import * as lsm from $to_relative "/js/lsm.js"
 | 
			
		||||
const lsm = require("./lsm.js")
 | 
			
		||||
 | 
			
		||||
function resolveMxc(url, size, method) {
 | 
			
		||||
	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
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,4 +1,4 @@
 | 
			
		|||
import {q} from $to_relative "/js/basic.js"
 | 
			
		||||
const {q} = require("./basic.js")
 | 
			
		||||
 | 
			
		||||
let state = "CLOSED"
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										20
									
								
								src/js/lazy-load-module.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,20 @@
 | 
			
		|||
// I hate this with passion
 | 
			
		||||
async function lazyLoad(url) {
 | 
			
		||||
	const cache = window.lazyLoadCache || new Map()
 | 
			
		||||
	window.lazyLoadCache = cache
 | 
			
		||||
	if (cache.get(url)) return cache.get(url)
 | 
			
		||||
 | 
			
		||||
	const module = loadModuleWithoutCache(url)
 | 
			
		||||
	cache.set(url, module)
 | 
			
		||||
	return module
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Loads the module without caching
 | 
			
		||||
async function loadModuleWithoutCache(url) {
 | 
			
		||||
	const src = await fetch(url).then(r => r.text())
 | 
			
		||||
	let module = {}
 | 
			
		||||
	eval(src)
 | 
			
		||||
	return module.exports
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = {lazyLoad}
 | 
			
		||||
							
								
								
									
										167
									
								
								src/js/login.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,167 @@
 | 
			
		|||
const {q, ElemJS, ejs} = require("./basic.js")
 | 
			
		||||
 | 
			
		||||
const password = q("#password")
 | 
			
		||||
const homeserver = q("#homeserver")
 | 
			
		||||
 | 
			
		||||
class Username extends ElemJS {
 | 
			
		||||
	constructor() {
 | 
			
		||||
		super(q("#username"))
 | 
			
		||||
 | 
			
		||||
		this.on("change", this.updateServer.bind(this))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	isValid() {
 | 
			
		||||
		return !!this.element.value.match(/^@?[a-z0-9._=\/-]+(?::[a-zA-Z0-9.:\[\]-]+)?$/)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	getUsername() {
 | 
			
		||||
		return this.element.value.match(/^@?([a-z0-9._=\/-]+)/)[1]
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	getServer() {
 | 
			
		||||
		const server = this.element.value.match(/^@?[a-z0-9._=\?-]+:([a-zA-Z0-9.:\[\]-]+)$/)
 | 
			
		||||
		if (server && server[1]) return server[1]
 | 
			
		||||
		else return null
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	updateServer() {
 | 
			
		||||
		if (!this.isValid()) return
 | 
			
		||||
		if (this.getServer()) homeserver.value = this.getServer()
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const username = new Username()
 | 
			
		||||
 | 
			
		||||
class Feedback extends ElemJS {
 | 
			
		||||
	constructor() {
 | 
			
		||||
		super(q("#feedback"))
 | 
			
		||||
		this.loading = false
 | 
			
		||||
		this.loadingIcon = ejs("span").class("loading-icon")
 | 
			
		||||
		this.messageSpan = ejs("span")
 | 
			
		||||
		this.child(this.messageSpan)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	setLoading(state) {
 | 
			
		||||
		if (this.loading && !state) {
 | 
			
		||||
			this.loadingIcon.remove()
 | 
			
		||||
		} else if (!this.loading && state) {
 | 
			
		||||
			this.childAt(0, this.loadingIcon)
 | 
			
		||||
		}
 | 
			
		||||
		this.loading = state
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	message(content, isError) {
 | 
			
		||||
		this.removeClass("form-feedback")
 | 
			
		||||
		this.removeClass("form-error")
 | 
			
		||||
		if (content) this.class("form-feedback")
 | 
			
		||||
		if(isError) this.class("form-error")
 | 
			
		||||
 | 
			
		||||
		this.messageSpan.text(content)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const feedback = new Feedback()
 | 
			
		||||
 | 
			
		||||
class Form extends ElemJS {
 | 
			
		||||
	constructor() {
 | 
			
		||||
		super(q("#form"))
 | 
			
		||||
 | 
			
		||||
		this.processing = false
 | 
			
		||||
 | 
			
		||||
		this.on("submit", this.submit.bind(this))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async submit() {
 | 
			
		||||
		if (this.processing) return
 | 
			
		||||
		this.processing = true
 | 
			
		||||
		if (!username.isValid()) return this.cancel("Username is not valid.")
 | 
			
		||||
 | 
			
		||||
		// Resolve homeserver address
 | 
			
		||||
		let domain
 | 
			
		||||
		try {
 | 
			
		||||
			domain = await this.findHomeserver(homeserver.value)
 | 
			
		||||
		} catch(e) {
 | 
			
		||||
			return this.cancel(e.message)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Request access token
 | 
			
		||||
		this.status("Logging in...")
 | 
			
		||||
		const root = await fetch(`${domain}/_matrix/client/r0/login`, {
 | 
			
		||||
			method: "POST",
 | 
			
		||||
			body: JSON.stringify({
 | 
			
		||||
				type: "m.login.password",
 | 
			
		||||
				user: username.getUsername(),
 | 
			
		||||
				password: password.value
 | 
			
		||||
			})
 | 
			
		||||
		}).then(res => res.json())
 | 
			
		||||
 | 
			
		||||
		if (!root.access_token) {
 | 
			
		||||
			if (root.error) {
 | 
			
		||||
				this.cancel(`Server said: ${root.error}`)
 | 
			
		||||
			} else {
 | 
			
		||||
				this.cancel("Login mysteriously failed.")
 | 
			
		||||
				console.error(root)
 | 
			
		||||
			}
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		localStorage.setItem("mx_user_id", root.user_id)
 | 
			
		||||
		localStorage.setItem("domain", domain)
 | 
			
		||||
		localStorage.setItem("access_token", root.access_token)
 | 
			
		||||
 | 
			
		||||
		location.assign("../")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async findHomeserver(address, maxDepth = 5) {
 | 
			
		||||
 | 
			
		||||
		//Protects from servers sending us on a redirect loop
 | 
			
		||||
		maxDepth--
 | 
			
		||||
		if (maxDepth <= 0) throw new Error(`Failed to look up homeserver, maximum search depth reached`)
 | 
			
		||||
	
 | 
			
		||||
		//Normalise the address
 | 
			
		||||
		if (!address.match(/^https?:\/\//)) {
 | 
			
		||||
			console.warn(`${address} doesn't specify the protocol, assuming https`)
 | 
			
		||||
			address = "https://" + address
 | 
			
		||||
		}
 | 
			
		||||
		address = address.replace(/\/*$/, "")
 | 
			
		||||
		
 | 
			
		||||
		this.status(`Looking up homeserver... trying ${address}`)
 | 
			
		||||
	
 | 
			
		||||
		// Check if we found the actual matrix server
 | 
			
		||||
		try {
 | 
			
		||||
			const versionsReq = await fetch(`${address}/_matrix/client/versions`)
 | 
			
		||||
			if (versionsReq.ok) {
 | 
			
		||||
				const versions = await versionsReq.json()
 | 
			
		||||
				if (Array.isArray(versions.versions)) return address
 | 
			
		||||
			}
 | 
			
		||||
		} catch(e) {}
 | 
			
		||||
	
 | 
			
		||||
		// Find the next matrix server in the chain
 | 
			
		||||
		const root = await fetch(`${address}/.well-known/matrix/client`).then(res => res.json()).catch(e => {
 | 
			
		||||
 			console.error(e)
 | 
			
		||||
			throw new Error(`Failed to look up server ${address}`)
 | 
			
		||||
		})
 | 
			
		||||
 | 
			
		||||
		let nextAddress = root["m.homeserver"].base_url
 | 
			
		||||
		nextAddress = nextAddress.replace(/\/*$/, "")
 | 
			
		||||
 | 
			
		||||
		if (address === nextAddress) {
 | 
			
		||||
			throw new Error(`Failed to look up server ${address}, /.well-known/matrix/client found a redirect loop`);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return this.findHomeserver(nextAddress, maxDepth)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	status(message) {
 | 
			
		||||
		feedback.setLoading(true)
 | 
			
		||||
		feedback.message(message)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	cancel(message) {
 | 
			
		||||
		this.processing = false
 | 
			
		||||
		feedback.setLoading(false)
 | 
			
		||||
		feedback.message(message, true)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const form = new Form()
 | 
			
		||||
| 
						 | 
				
			
			@ -8,4 +8,4 @@ function set(name, value) {
 | 
			
		|||
 | 
			
		||||
window.lsm = {get, set}
 | 
			
		||||
 | 
			
		||||
export {get, set}
 | 
			
		||||
module.exports = {get, set}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										11
									
								
								src/js/main.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,11 @@
 | 
			
		|||
require("./focus.js")
 | 
			
		||||
const groups = require("./groups.js")
 | 
			
		||||
const chat_input = require("./chat-input.js")
 | 
			
		||||
const room_picker = require("./room-picker.js")
 | 
			
		||||
const sync = require("./sync/sync.js")
 | 
			
		||||
const chat = require("./chat.js")
 | 
			
		||||
require("./typing.js")
 | 
			
		||||
 | 
			
		||||
if (!localStorage.getItem("access_token")) {
 | 
			
		||||
	location.assign("./login/")
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										149
									
								
								src/js/read-marker.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,149 @@
 | 
			
		|||
const {ElemJS, ejs, q} = require("./basic.js")
 | 
			
		||||
const {store} = require("./store/store.js")
 | 
			
		||||
const lsm = require("./lsm.js")
 | 
			
		||||
 | 
			
		||||
function markFullyRead(roomID, eventID) {
 | 
			
		||||
	return fetch(`${lsm.get("domain")}/_matrix/client/r0/rooms/${roomID}/read_markers?access_token=${lsm.get("access_token")}`, {
 | 
			
		||||
		method: "POST",
 | 
			
		||||
		body: JSON.stringify({
 | 
			
		||||
			"m.fully_read": eventID,
 | 
			
		||||
			"m.read": eventID
 | 
			
		||||
		})
 | 
			
		||||
	})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class ReadBanner extends ElemJS {
 | 
			
		||||
	constructor() {
 | 
			
		||||
		super(q("#c-chat-banner"))
 | 
			
		||||
 | 
			
		||||
		this.newMessages = ejs("span")
 | 
			
		||||
		this.child(
 | 
			
		||||
			ejs("div").class("c-chat-banner__inner").child(
 | 
			
		||||
				ejs("button").class("c-chat-banner__part").on("click", this.jumpTo.bind(this)).child(
 | 
			
		||||
					ejs("div").class("c-chat-banner__part-inner")
 | 
			
		||||
						.child(this.newMessages)
 | 
			
		||||
						.addText(" new messages")
 | 
			
		||||
				),
 | 
			
		||||
				ejs("button").class("c-chat-banner__part", "c-chat-banner__last").on("click", this.markRead.bind(this)).child(
 | 
			
		||||
					ejs("div").class("c-chat-banner__part-inner").text("Mark as read")
 | 
			
		||||
				)
 | 
			
		||||
			)
 | 
			
		||||
		)
 | 
			
		||||
 | 
			
		||||
		store.activeRoom.subscribe("changeSelf", this.render.bind(this))
 | 
			
		||||
		store.notificationsChange.subscribe("changeSelf", this.render.bind(this))
 | 
			
		||||
		this.render()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async jumpTo() {
 | 
			
		||||
		if (!store.activeRoom.exists()) return
 | 
			
		||||
		const timeline = store.activeRoom.value().timeline
 | 
			
		||||
		const readMarker = timeline.readMarker
 | 
			
		||||
		while (true) {
 | 
			
		||||
			if (readMarker.attached) {
 | 
			
		||||
				readMarker.element.scrollIntoView({behavior: "smooth", block: "center"})
 | 
			
		||||
				return
 | 
			
		||||
			} else {
 | 
			
		||||
				q("#c-chat-messages").scrollTo({
 | 
			
		||||
					top: 0,
 | 
			
		||||
					left: 0,
 | 
			
		||||
					behavior: "smooth"
 | 
			
		||||
				})
 | 
			
		||||
				await new Promise(resolve => {
 | 
			
		||||
					const unsubscribe = timeline.subscribe("afterScrollbackLoad", () => {
 | 
			
		||||
						unsubscribe()
 | 
			
		||||
						resolve()
 | 
			
		||||
					})
 | 
			
		||||
				})
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	markRead() {
 | 
			
		||||
		if (!store.activeRoom.exists()) return
 | 
			
		||||
		const timeline = store.activeRoom.value().timeline
 | 
			
		||||
		markFullyRead(timeline.id, timeline.latestEventID)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		let count = 0
 | 
			
		||||
		if (store.activeRoom.exists()) {
 | 
			
		||||
			count = store.activeRoom.value().number.state.unreads
 | 
			
		||||
		}
 | 
			
		||||
		if (count !== 0) {
 | 
			
		||||
			this.newMessages.text(count)
 | 
			
		||||
			this.class("c-chat-banner--active")
 | 
			
		||||
		} else {
 | 
			
		||||
			this.removeClass("c-chat-banner--active")
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
const readBanner = new ReadBanner()
 | 
			
		||||
 | 
			
		||||
class ReadMarker extends ElemJS {
 | 
			
		||||
	constructor(timeline) {
 | 
			
		||||
		super("div")
 | 
			
		||||
 | 
			
		||||
		this.class("c-read-marker")
 | 
			
		||||
		this.loadingIcon = ejs("div")
 | 
			
		||||
			.class("c-read-marker__loading", "loading-icon")
 | 
			
		||||
			.style("display", "none")
 | 
			
		||||
		this.child(
 | 
			
		||||
			ejs("div").class("c-read-marker__inner").child(
 | 
			
		||||
				ejs("div").class("c-read-marker__text").child(this.loadingIcon).addText("New")
 | 
			
		||||
			)
 | 
			
		||||
		)
 | 
			
		||||
 | 
			
		||||
		let processing = false
 | 
			
		||||
		const observer = new IntersectionObserver(entries => {
 | 
			
		||||
			const entry = entries[0]
 | 
			
		||||
			if (!entry.isIntersecting) return
 | 
			
		||||
			if (processing) return
 | 
			
		||||
			processing = true
 | 
			
		||||
			this.loadingIcon.style("display", "")
 | 
			
		||||
			markFullyRead(this.timeline.id, this.timeline.latestEventID).then(() => {
 | 
			
		||||
				this.loadingIcon.style("display", "none")
 | 
			
		||||
				processing = false
 | 
			
		||||
			})
 | 
			
		||||
		}, {
 | 
			
		||||
			root: document.getElementById("c-chat-messages"),
 | 
			
		||||
			rootMargin: "-80px 0px 0px 0px", // marker must be this distance inside the top of the screen to be counted as read
 | 
			
		||||
			threshold: 0.01
 | 
			
		||||
		})
 | 
			
		||||
		observer.observe(this.element)
 | 
			
		||||
 | 
			
		||||
		this.attached = false
 | 
			
		||||
		this.timeline = timeline
 | 
			
		||||
		this.timeline.userReads.get(lsm.get("mx_user_id")).subscribe("changeSelf", (_, eventID) => {
 | 
			
		||||
			// read marker updated, attach to it
 | 
			
		||||
			const event = this.timeline.map.get(eventID)
 | 
			
		||||
			this.attach(event)
 | 
			
		||||
		})
 | 
			
		||||
		this.timeline.subscribe("afterChange", () => {
 | 
			
		||||
			// timeline has new events, attach to last read one
 | 
			
		||||
			const eventID = this.timeline.userReads.get(lsm.get("mx_user_id")).value()
 | 
			
		||||
			const event = this.timeline.map.get(eventID)
 | 
			
		||||
			this.attach(event)
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	attach(event) {
 | 
			
		||||
		if (event && event.data.origin_server_ts !== this.timeline.latest) {
 | 
			
		||||
			this.class("c-read-marker--attached")
 | 
			
		||||
			event.element.insertAdjacentElement("beforeend", this.element)
 | 
			
		||||
			this.attached = true
 | 
			
		||||
		} else {
 | 
			
		||||
			this.removeClass("c-read-marker--attached")
 | 
			
		||||
			this.attached = false
 | 
			
		||||
		}
 | 
			
		||||
		if (store.activeRoom.value() === this.timeline.room) {
 | 
			
		||||
			readBanner.render()
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
	ReadMarker,
 | 
			
		||||
	readBanner,
 | 
			
		||||
	markFullyRead
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,10 +1,10 @@
 | 
			
		|||
import {q, ElemJS, ejs} from $to_relative "/js/basic.js"
 | 
			
		||||
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
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,120 @@
 | 
			
		|||
const {ElemJS, ejs} = require("./basic.js")
 | 
			
		||||
const {store} = require("./store/store.js")
 | 
			
		||||
const {resolveMxc} = require("./functions.js")
 | 
			
		||||
 | 
			
		||||
function nameToColor(str) {
 | 
			
		||||
	// code from element's react sdk
 | 
			
		||||
	const colors = ["#55a7f0", "#da55ff", "#1bc47c", "#ea657e", "#fd8637", "#22cec6", "#8c8de3", "#71bf22"]
 | 
			
		||||
	let hash = 0
 | 
			
		||||
	let i
 | 
			
		||||
	let chr
 | 
			
		||||
	if (str.length === 0) {
 | 
			
		||||
		return hash
 | 
			
		||||
	}
 | 
			
		||||
	for (i = 0; i < str.length; i++) {
 | 
			
		||||
		chr = str.charCodeAt(i)
 | 
			
		||||
		hash = ((hash << 5) - hash) + chr
 | 
			
		||||
		hash |= 0
 | 
			
		||||
	}
 | 
			
		||||
	hash = Math.abs(hash) % 8
 | 
			
		||||
	return colors[hash]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class Avatar extends ElemJS {
 | 
			
		||||
	constructor() {
 | 
			
		||||
		super("div")
 | 
			
		||||
		this.class("c-message-group__avatar")
 | 
			
		||||
 | 
			
		||||
		this.mxc = undefined
 | 
			
		||||
		this.image = null
 | 
			
		||||
 | 
			
		||||
		this.update(null)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	update(mxc) {
 | 
			
		||||
		if (mxc === this.mxc) return
 | 
			
		||||
		this.mxc = mxc
 | 
			
		||||
		this.hasImage = !!mxc
 | 
			
		||||
		if (this.hasImage) {
 | 
			
		||||
			const size = 96
 | 
			
		||||
			const url = resolveMxc(mxc, size, "crop")
 | 
			
		||||
			this.image = ejs("img").class("c-message-group__icon").attribute("src", url).attribute("width", size).attribute("height", size)
 | 
			
		||||
			this.image.on("error", this.onError.bind(this))
 | 
			
		||||
		}
 | 
			
		||||
		this.render()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	onError() {
 | 
			
		||||
		this.hasImage = false
 | 
			
		||||
		this.render()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		this.clearChildren()
 | 
			
		||||
		if (this.hasImage) {
 | 
			
		||||
			this.child(this.image)
 | 
			
		||||
		} else {
 | 
			
		||||
			this.child(
 | 
			
		||||
				ejs("div").class("c-message-group__icon", "c-message-group__icon--no-icon")
 | 
			
		||||
			)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** Must update at least once to render. */
 | 
			
		||||
class Name extends ElemJS {
 | 
			
		||||
	constructor() {
 | 
			
		||||
		super("div")
 | 
			
		||||
		this.class("c-message-group__name")
 | 
			
		||||
 | 
			
		||||
		/**
 | 
			
		||||
		 * Keeps track of whether we have the proper display name or not.
 | 
			
		||||
		 * If we do, then we shoudn't override it with the mxid if the name becomes unavailable.
 | 
			
		||||
		 */
 | 
			
		||||
		this.hasName = false
 | 
			
		||||
		this.name = ""
 | 
			
		||||
		this.mxid = ""
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	update(event) {
 | 
			
		||||
		this.mxid = event.state_key
 | 
			
		||||
		if (event.content.displayname) {
 | 
			
		||||
			this.hasName = true
 | 
			
		||||
			this.name = event.content.displayname
 | 
			
		||||
		} else if (!this.hasName) {
 | 
			
		||||
			this.name = this.mxid
 | 
			
		||||
		}
 | 
			
		||||
		this.render()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		// set text
 | 
			
		||||
		this.text(this.name)
 | 
			
		||||
		// set color
 | 
			
		||||
		this.style("color", nameToColor(this.mxid))
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class Sender {
 | 
			
		||||
	constructor(roomID, mxid) {
 | 
			
		||||
		this.sender = store.rooms.get(roomID).value().members.get(mxid)
 | 
			
		||||
		this.name = new Name()
 | 
			
		||||
		this.avatar = new Avatar()
 | 
			
		||||
		this.sender.subscribe("changeSelf", this.update.bind(this))
 | 
			
		||||
		this.update()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	update() {
 | 
			
		||||
		if (this.sender.exists()) {
 | 
			
		||||
			// name
 | 
			
		||||
			this.name.update(this.sender.value())
 | 
			
		||||
 | 
			
		||||
			// avatar
 | 
			
		||||
			this.avatar.update(this.sender.value().content.avatar_url)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
	Sender
 | 
			
		||||
}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,41 +0,0 @@
 | 
			
		|||
import {Subscribable} from $to_relative "/js/store/Subscribable.js"
 | 
			
		||||
import {SubscribeValue} from $to_relative "/js/store/SubscribeValue.js"
 | 
			
		||||
 | 
			
		||||
class SubscribeMap extends Subscribable {
 | 
			
		||||
	constructor() {
 | 
			
		||||
		super()
 | 
			
		||||
		Object.assign(this.events, {
 | 
			
		||||
			addItem: [],
 | 
			
		||||
			changeItem: [],
 | 
			
		||||
			removeItem: []
 | 
			
		||||
		})
 | 
			
		||||
		this.map = new Map()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	has(key) {
 | 
			
		||||
		return this.map.has(key) && this.map.get(key).exists()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	get(key) {
 | 
			
		||||
		if (this.map.has(key)) {
 | 
			
		||||
			return this.map.get(key)
 | 
			
		||||
		} else {
 | 
			
		||||
			this.map.set(key, new SubscribeValue())
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	set(key, value) {
 | 
			
		||||
		let s
 | 
			
		||||
		if (this.map.has(key)) {
 | 
			
		||||
			s = this.map.get(key).set(value)
 | 
			
		||||
			this.broadcast("changeItem", key)
 | 
			
		||||
		} else {
 | 
			
		||||
			s = new SubscribeValue().set(value)
 | 
			
		||||
			this.map.set(key, s)
 | 
			
		||||
			this.broadcast("addItem", key)
 | 
			
		||||
		}
 | 
			
		||||
		return s
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export {SubscribeMap}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,7 +1,7 @@
 | 
			
		|||
import {Subscribable} from $to_relative "/js/store/Subscribable.js"
 | 
			
		||||
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}
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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}
 | 
			
		||||
							
								
								
									
										74
									
								
								src/js/store/subscribe_map.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,74 @@
 | 
			
		|||
const {Subscribable} = require("./subscribable.js")
 | 
			
		||||
 | 
			
		||||
class SubscribeMap extends Subscribable {
 | 
			
		||||
	constructor(inner) {
 | 
			
		||||
		super()
 | 
			
		||||
		this.inner = inner
 | 
			
		||||
		Object.assign(this.events, {
 | 
			
		||||
			addItem: [],
 | 
			
		||||
			editItem: [],
 | 
			
		||||
			deleteItem: [],
 | 
			
		||||
			changeItem: [],
 | 
			
		||||
			askSet: []
 | 
			
		||||
		})
 | 
			
		||||
		Object.assign(this.eventDeps, {
 | 
			
		||||
			addItem: ["changeItem"],
 | 
			
		||||
			editItem: ["changeItem"],
 | 
			
		||||
			deleteItem: ["changeItem"],
 | 
			
		||||
			changeItem: [],
 | 
			
		||||
			askSet: []
 | 
			
		||||
		})
 | 
			
		||||
		this.map = new Map()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	has(key) {
 | 
			
		||||
		return this.map.has(key) && this.map.get(key).exists()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	get(key) {
 | 
			
		||||
		if (this.map.has(key)) {
 | 
			
		||||
			return this.map.get(key)
 | 
			
		||||
		} else {
 | 
			
		||||
			const item = new this.inner()
 | 
			
		||||
			this.map.set(key, item)
 | 
			
		||||
			return item
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	forEach(f) {
 | 
			
		||||
		for (const entry of this.map.entries()) {
 | 
			
		||||
			f(entry[0], entry[1])
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	askSet(key, value) {
 | 
			
		||||
		this.broadcast("askSet", {key, value})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	set(key, value) {
 | 
			
		||||
		let s
 | 
			
		||||
		if (this.map.has(key)) {
 | 
			
		||||
			const exists = this.map.get(key).exists()
 | 
			
		||||
			s = this.map.get(key).set(value)
 | 
			
		||||
			if (exists) {
 | 
			
		||||
				this.broadcast("editItem", key)
 | 
			
		||||
			} else {
 | 
			
		||||
				this.broadcast("addItem", key)
 | 
			
		||||
			}
 | 
			
		||||
		} else {
 | 
			
		||||
			s = new this.inner().set(value)
 | 
			
		||||
			this.map.set(key, s)
 | 
			
		||||
			this.broadcast("addItem", key)
 | 
			
		||||
		}
 | 
			
		||||
		return s
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	delete(key) {
 | 
			
		||||
		if (this.backing.has(key)) {
 | 
			
		||||
			this.backing.delete(key)
 | 
			
		||||
			this.broadcast("deleteItem", key)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = {SubscribeMap}
 | 
			
		||||
| 
						 | 
				
			
			@ -1,5 +1,5 @@
 | 
			
		|||
import {Subscribable} from $to_relative "/js/store/Subscribable.js"
 | 
			
		||||
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}
 | 
			
		||||
| 
						 | 
				
			
			@ -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}
 | 
			
		||||
| 
						 | 
				
			
			@ -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}
 | 
			
		||||
| 
						 | 
				
			
			@ -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
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,408 @@
 | 
			
		|||
const {ElemJS, ejs, q} = require("./basic.js")
 | 
			
		||||
const {Subscribable} = require("./store/subscribable.js")
 | 
			
		||||
const {SubscribeValue} = require("./store/subscribe_value.js")
 | 
			
		||||
const {SubscribeMap} = require("./store/subscribe_map.js")
 | 
			
		||||
const {store} = require("./store/store.js")
 | 
			
		||||
const {Anchor} = require("./anchor.js")
 | 
			
		||||
const {Sender} = require("./sender.js")
 | 
			
		||||
const {ReadMarker, markFullyRead} = require("./read-marker.js")
 | 
			
		||||
const lsm = require("./lsm.js")
 | 
			
		||||
const {resolveMxc} = require("./functions.js")
 | 
			
		||||
const {renderEvent} = require("./events/render-event")
 | 
			
		||||
const {dateFormatter} = require("./date-formatter")
 | 
			
		||||
 | 
			
		||||
let debug = false
 | 
			
		||||
 | 
			
		||||
const NO_MAX = Symbol("NO_MAX")
 | 
			
		||||
 | 
			
		||||
let sentIndex = 0
 | 
			
		||||
 | 
			
		||||
function getTxnId() {
 | 
			
		||||
	return Date.now() + (sentIndex++)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function eventSearch(list, event, min = 0, max = NO_MAX) {
 | 
			
		||||
	if (list.length === 0) return {success: false, i: 0}
 | 
			
		||||
 | 
			
		||||
	if (max === NO_MAX) max = list.length - 1
 | 
			
		||||
	let mid = Math.floor((max + min) / 2)
 | 
			
		||||
	// success condition
 | 
			
		||||
	if (list[mid] && list[mid].data.event_id === event.data.event_id) return {success: true, i: mid}
 | 
			
		||||
	// failed condition
 | 
			
		||||
	if (min >= max) {
 | 
			
		||||
		while (mid !== -1 && (!list[mid] || list[mid].data.origin_server_ts > event.data.origin_server_ts)) mid--
 | 
			
		||||
		return {
 | 
			
		||||
			success: false,
 | 
			
		||||
			i: mid + 1
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	// recurse (below)
 | 
			
		||||
	if (list[mid].data.origin_server_ts > event.data.origin_server_ts) return eventSearch(list, event, min, mid - 1)
 | 
			
		||||
	// recurse (above)
 | 
			
		||||
	else return eventSearch(list, event, mid + 1, max)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class EventGroup extends ElemJS {
 | 
			
		||||
	constructor(reactive, list) {
 | 
			
		||||
		super("div")
 | 
			
		||||
		this.class("c-message-group")
 | 
			
		||||
		this.reactive = reactive
 | 
			
		||||
		this.list = list
 | 
			
		||||
		this.data = {
 | 
			
		||||
			sender: list[0].data.sender,
 | 
			
		||||
			origin_server_ts: list[0].data.origin_server_ts
 | 
			
		||||
		}
 | 
			
		||||
		this.sender = new Sender(this.reactive.id, this.data.sender)
 | 
			
		||||
		this.child(
 | 
			
		||||
			this.sender.avatar,
 | 
			
		||||
			this.messages = ejs("div").class("c-message-group__messages").child(
 | 
			
		||||
				ejs("div").class("c-message-group__intro").child(
 | 
			
		||||
					this.sender.name,
 | 
			
		||||
					ejs("div").class("c-message-group__date").text(dateFormatter.format(this.data.origin_server_ts))
 | 
			
		||||
				),
 | 
			
		||||
				...this.list
 | 
			
		||||
			)
 | 
			
		||||
		)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	canGroup() {
 | 
			
		||||
		if (this.list.length) return this.list[0].canGroup()
 | 
			
		||||
		else return true
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	addEvent(event) {
 | 
			
		||||
		const index = eventSearch(this.list, event).i
 | 
			
		||||
		event.setGroup(this)
 | 
			
		||||
		this.list.splice(index, 0, event)
 | 
			
		||||
		this.messages.childAt(index + 1, event)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	removeEvent(event) {
 | 
			
		||||
		const search = eventSearch(this.list, event)
 | 
			
		||||
		if (!search.success) throw new Error(`Event ${event.data.event_id} not found in this group`)
 | 
			
		||||
		const index = search.i
 | 
			
		||||
		// actually remove the event
 | 
			
		||||
		this.list.splice(index, 1)
 | 
			
		||||
		event.remove() // should get everything else
 | 
			
		||||
		if (this.list.length === 0) this.reactive.removeGroup(this)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/** Displays a spinner and creates an event to notify timeline to load more messages */
 | 
			
		||||
class LoadMore extends ElemJS {
 | 
			
		||||
	constructor(id) {
 | 
			
		||||
		super("div")
 | 
			
		||||
		this.class("c-message-notice")
 | 
			
		||||
		this.id = id
 | 
			
		||||
 | 
			
		||||
		this.child(
 | 
			
		||||
			ejs("div").class("c-message-notice__inner").child(
 | 
			
		||||
				ejs("span").class("loading-icon"),
 | 
			
		||||
				ejs("span").text("Loading more...")
 | 
			
		||||
			)
 | 
			
		||||
		)
 | 
			
		||||
		const intersection_observer = new IntersectionObserver(e => this.intersectionHandler(e))
 | 
			
		||||
		intersection_observer.observe(this.element)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	intersectionHandler(e) {
 | 
			
		||||
		if (e.some(e => e.isIntersecting)) {
 | 
			
		||||
			store.rooms.get(this.id).value().timeline.loadScrollback()
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class ReactiveTimeline extends ElemJS {
 | 
			
		||||
	constructor(id, list) {
 | 
			
		||||
		super("div")
 | 
			
		||||
		this.class("c-event-groups")
 | 
			
		||||
		this.id = id
 | 
			
		||||
		this.list = list
 | 
			
		||||
		this.loadMore = new LoadMore(this.id)
 | 
			
		||||
		this.render()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	addEvent(event) {
 | 
			
		||||
		this.loadMore.remove()
 | 
			
		||||
		// if (debug) console.log("running search", this.list, event)
 | 
			
		||||
		// if (debug) debugger;
 | 
			
		||||
		const search = eventSearch(this.list, event)
 | 
			
		||||
		// console.log(search, this.list.map(l => l.data.sender), event.data)
 | 
			
		||||
		if (!search.success) {
 | 
			
		||||
			if (search.i >= 1) {
 | 
			
		||||
				// add at end
 | 
			
		||||
				this.tryAddGroups(event, [search.i - 1, search.i])
 | 
			
		||||
			} else {
 | 
			
		||||
				// add at start
 | 
			
		||||
				this.tryAddGroups(event, [0, -1])
 | 
			
		||||
			}
 | 
			
		||||
		} else {
 | 
			
		||||
			this.tryAddGroups(event, [search.i])
 | 
			
		||||
		}
 | 
			
		||||
		this.loadMore = new LoadMore(this.id)
 | 
			
		||||
		this.childAt(0, this.loadMore)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	tryAddGroups(event, indices) {
 | 
			
		||||
		const createGroupAt = i => {
 | 
			
		||||
			// if (printed++ < 100) console.log("tryadd success, created group")
 | 
			
		||||
			if (i === -1) {
 | 
			
		||||
				// here, -1 means at the start, before the first group
 | 
			
		||||
				i = 0 // jank but it does the trick
 | 
			
		||||
			}
 | 
			
		||||
			if (event.canGroup()) {
 | 
			
		||||
				const group = new EventGroup(this, [event])
 | 
			
		||||
				this.list.splice(i, 0, group)
 | 
			
		||||
				this.childAt(i, group)
 | 
			
		||||
				event.setGroup(group)
 | 
			
		||||
			} else {
 | 
			
		||||
				this.list.splice(i, 0, event)
 | 
			
		||||
				this.childAt(i, event)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		const success = indices.some(i => {
 | 
			
		||||
			if (!this.list[i]) {
 | 
			
		||||
				createGroupAt(i)
 | 
			
		||||
				return true
 | 
			
		||||
			} else if (event.canGroup() && this.list[i] && this.list[i].canGroup() && this.list[i].data.sender === event.data.sender) {
 | 
			
		||||
				// if (printed++ < 100) console.log("tryadd success, using existing group")
 | 
			
		||||
				this.list[i].addEvent(event)
 | 
			
		||||
				return true
 | 
			
		||||
			}
 | 
			
		||||
		})
 | 
			
		||||
		// if (!success) console.log("tryadd failure", indices, this.list.map(l => l.data.sender), event.data) // I believe all the bugs are now fixed. Lol.
 | 
			
		||||
		if (!success) createGroupAt(indices[0])
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	removeGroup(group) {
 | 
			
		||||
		const index = this.list.indexOf(group)
 | 
			
		||||
		this.list.splice(index, 1)
 | 
			
		||||
		group.remove() // should get everything else
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		this.clearChildren()
 | 
			
		||||
		this.child(this.loadMore)
 | 
			
		||||
		this.list.forEach(group => this.child(group))
 | 
			
		||||
		this.anchor = new Anchor()
 | 
			
		||||
		this.child(this.anchor)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class Timeline extends Subscribable {
 | 
			
		||||
	constructor(room) {
 | 
			
		||||
		super()
 | 
			
		||||
		Object.assign(this.events, {
 | 
			
		||||
			beforeChange: [],
 | 
			
		||||
			afterChange: [],
 | 
			
		||||
			beforeScrollbackLoad: [],
 | 
			
		||||
			afterScrollbackLoad: [],
 | 
			
		||||
		})
 | 
			
		||||
		Object.assign(this.eventDeps, {
 | 
			
		||||
			beforeChange: [],
 | 
			
		||||
			afterChange: [],
 | 
			
		||||
			beforeScrollbackLoad: [],
 | 
			
		||||
			afterScrollbackLoad: [],
 | 
			
		||||
		})
 | 
			
		||||
		this.room = room
 | 
			
		||||
		this.id = this.room.id
 | 
			
		||||
		this.list = []
 | 
			
		||||
		this.map = new Map()
 | 
			
		||||
		this.reactiveTimeline = new ReactiveTimeline(this.id, [])
 | 
			
		||||
		this.latest = 0
 | 
			
		||||
		this.latestEventID = null
 | 
			
		||||
		this.pending = new Set()
 | 
			
		||||
		this.pendingEdits = []
 | 
			
		||||
		this.typing = new SubscribeValue().set([])
 | 
			
		||||
		this.userReads = new SubscribeMap(SubscribeValue)
 | 
			
		||||
		this.readMarker = new ReadMarker(this)
 | 
			
		||||
		this.from = null
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	updateStateEvents(events) {
 | 
			
		||||
		for (const eventData of events) {
 | 
			
		||||
			let id = eventData.event_id
 | 
			
		||||
			if (eventData.type === "m.room.member") {
 | 
			
		||||
				// update members
 | 
			
		||||
				if (eventData.membership !== "leave") {
 | 
			
		||||
					const member = this.room.members.get(eventData.state_key)
 | 
			
		||||
					// only use the latest state
 | 
			
		||||
					if (!member.exists() || eventData.origin_server_ts > member.data.origin_server_ts) {
 | 
			
		||||
						member.set(eventData)
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	updateEvents(events) {
 | 
			
		||||
		this.broadcast("beforeChange")
 | 
			
		||||
		// handle state events
 | 
			
		||||
		this.updateStateEvents(events)
 | 
			
		||||
		for (const eventData of events) {
 | 
			
		||||
			// set variables
 | 
			
		||||
			let id = eventData.event_id
 | 
			
		||||
			if (eventData.origin_server_ts > this.latest) {
 | 
			
		||||
				this.latest = eventData.origin_server_ts
 | 
			
		||||
				this.latestEventID = id
 | 
			
		||||
			}
 | 
			
		||||
			// handle local echoes
 | 
			
		||||
			if (eventData.sender === lsm.get("mx_user_id") && eventData.content && this.pending.has(eventData.content["chat.carbon.message.pending_id"])) {
 | 
			
		||||
				const pendingID = eventData.content["chat.carbon.message.pending_id"]
 | 
			
		||||
				if (id !== pendingID) {
 | 
			
		||||
					const target = this.map.get(pendingID)
 | 
			
		||||
					this.map.set(id, target)
 | 
			
		||||
					this.map.delete(pendingID)
 | 
			
		||||
					// update fully read marker - assume we have fully read up to messages we send
 | 
			
		||||
					markFullyRead(this.id, id)
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
			// handle timeline events
 | 
			
		||||
			if (this.map.has(id)) {
 | 
			
		||||
				// update existing event
 | 
			
		||||
				this.map.get(id).update(eventData)
 | 
			
		||||
			} else {
 | 
			
		||||
				// skip redacted events
 | 
			
		||||
				if (eventData.unsigned && eventData.unsigned.redacted_by) {
 | 
			
		||||
					continue
 | 
			
		||||
				}
 | 
			
		||||
				// handle redactions
 | 
			
		||||
				if (eventData.type === "m.room.redaction") {
 | 
			
		||||
					if (this.map.has(eventData.redacts)) this.map.get(eventData.redacts).removeEvent()
 | 
			
		||||
					continue
 | 
			
		||||
				}
 | 
			
		||||
				// handle edits
 | 
			
		||||
				if (eventData.type === "m.room.message" && eventData.content["m.relates_to"] && eventData.content["m.relates_to"].rel_type === "m.replace") {
 | 
			
		||||
					this.pendingEdits.push(eventData)
 | 
			
		||||
					continue
 | 
			
		||||
				}
 | 
			
		||||
				// add new event
 | 
			
		||||
				const event = renderEvent(eventData)
 | 
			
		||||
				this.map.set(id, event)
 | 
			
		||||
				this.reactiveTimeline.addEvent(event)
 | 
			
		||||
				// update read receipt for sender on their own event
 | 
			
		||||
				this.moveReadReceipt(eventData.sender, id)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		// apply edits
 | 
			
		||||
		this.pendingEdits = this.pendingEdits.filter(eventData => {
 | 
			
		||||
			const replaces = eventData.content["m.relates_to"].event_id
 | 
			
		||||
			if (this.map.has(replaces)) {
 | 
			
		||||
				const event = this.map.get(replaces)
 | 
			
		||||
				event.data.content = eventData.content["m.new_content"]
 | 
			
		||||
				event.setEdited(eventData.origin_server_ts)
 | 
			
		||||
				event.update(event.data)
 | 
			
		||||
				return false // handled; remove from list
 | 
			
		||||
			} else {
 | 
			
		||||
				return true // we don't have the event it edits yet; keep in list
 | 
			
		||||
			}
 | 
			
		||||
		})
 | 
			
		||||
		this.broadcast("afterChange")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	updateEphemeral(events) {
 | 
			
		||||
		for (const eventData of events) {
 | 
			
		||||
			if (eventData.type === "m.typing") {
 | 
			
		||||
				this.typing.set(eventData.content.user_ids)
 | 
			
		||||
			}
 | 
			
		||||
			if (eventData.type === "m.receipt") {
 | 
			
		||||
				for (const eventID of Object.keys(eventData.content)) {
 | 
			
		||||
					for (const user of Object.keys(eventData.content[eventID]["m.read"])) {
 | 
			
		||||
						this.moveReadReceipt(user, eventID)
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
				// console.log("Updated read receipts:", this.userReads)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	moveReadReceipt(user, eventID) {
 | 
			
		||||
		// check for a previous event to move from
 | 
			
		||||
		const prev = this.userReads.get(user)
 | 
			
		||||
		if (prev.exists()) {
 | 
			
		||||
			const prevID = prev.value()
 | 
			
		||||
			if (this.map.has(prevID) && this.map.has(eventID)) {
 | 
			
		||||
				// ensure new message came later
 | 
			
		||||
				if (this.map.get(eventID).data.origin_server_ts < this.map.get(prevID).data.origin_server_ts) return
 | 
			
		||||
				this.map.get(prevID).readBy.delete(user)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		// set on new message
 | 
			
		||||
		this.userReads.set(user, eventID)
 | 
			
		||||
		if (this.map.has(eventID)) this.map.get(eventID).readBy.add(user)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	updateUnreadCount(count) {
 | 
			
		||||
		this.room.number.update({unreads: count})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	updateNotificationCount(count) {
 | 
			
		||||
		this.room.number.update({notifications: count})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	removeEvent(id) {
 | 
			
		||||
		if (!this.map.has(id)) throw new Error(`Tried to delete event ID ${id} which does not exist`)
 | 
			
		||||
		this.map.get(id).removeEvent()
 | 
			
		||||
		this.map.delete(id)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	getTimeline() {
 | 
			
		||||
		return this.reactiveTimeline
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async loadScrollback() {
 | 
			
		||||
		debug = true
 | 
			
		||||
		if (!this.from) return // no more scrollback for this timeline
 | 
			
		||||
		const url = new URL(`${lsm.get("domain")}/_matrix/client/r0/rooms/${this.id}/messages`)
 | 
			
		||||
		url.searchParams.set("access_token", lsm.get("access_token"))
 | 
			
		||||
		url.searchParams.set("from", this.from)
 | 
			
		||||
		url.searchParams.set("dir", "b")
 | 
			
		||||
		url.searchParams.set("limit", "20")
 | 
			
		||||
		const filter = {
 | 
			
		||||
			lazy_load_members: true
 | 
			
		||||
		}
 | 
			
		||||
		url.searchParams.set("filter", JSON.stringify(filter))
 | 
			
		||||
 | 
			
		||||
		const root = await fetch(url.toString()).then(res => res.json())
 | 
			
		||||
 | 
			
		||||
		this.broadcast("beforeScrollbackLoad")
 | 
			
		||||
 | 
			
		||||
		this.from = root.end
 | 
			
		||||
		// console.log(this.updateEvents, root.chunk)
 | 
			
		||||
		if (root.state) this.updateStateEvents(root.state)
 | 
			
		||||
		if (root.chunk.length) {
 | 
			
		||||
			// there are events to display
 | 
			
		||||
			this.updateEvents(root.chunk)
 | 
			
		||||
		}
 | 
			
		||||
		if (!root.chunk.length || !root.end) {
 | 
			
		||||
			// we reached the top of the scrollback
 | 
			
		||||
			this.reactiveTimeline.loadMore.remove()
 | 
			
		||||
		}
 | 
			
		||||
		this.broadcast("afterScrollbackLoad")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	send(type, content) {
 | 
			
		||||
		const tx = getTxnId()
 | 
			
		||||
		const id = `pending$${tx}`
 | 
			
		||||
		this.pending.add(id)
 | 
			
		||||
		content["chat.carbon.message.pending_id"] = id
 | 
			
		||||
		const fakeEvent = {
 | 
			
		||||
			type,
 | 
			
		||||
			origin_server_ts: Date.now(),
 | 
			
		||||
			event_id: id,
 | 
			
		||||
			sender: lsm.get("mx_user_id"),
 | 
			
		||||
			content,
 | 
			
		||||
			pending: true
 | 
			
		||||
		}
 | 
			
		||||
		this.updateEvents([fakeEvent])
 | 
			
		||||
		return fetch(`${lsm.get("domain")}/_matrix/client/r0/rooms/${this.id}/send/m.room.message/${tx}?access_token=${lsm.get("access_token")}`, {
 | 
			
		||||
			method: "PUT",
 | 
			
		||||
			body: JSON.stringify(content),
 | 
			
		||||
			headers: {
 | 
			
		||||
				"Content-Type": "application/json"
 | 
			
		||||
			}
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = {Timeline}
 | 
			
		||||
							
								
								
									
										69
									
								
								src/js/typing.js
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,69 @@
 | 
			
		|||
const {ElemJS, ejs, q} = require("./basic")
 | 
			
		||||
const {store} = require("./store/store")
 | 
			
		||||
const lsm = require("./lsm")
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Maximum number of typing users to display all names for.
 | 
			
		||||
 * More will be shown as "X users are typing".
 | 
			
		||||
 */
 | 
			
		||||
const maxUsers = 4
 | 
			
		||||
 | 
			
		||||
function getMemberName(mxid) {
 | 
			
		||||
	return store.activeRoom.value().getMemberName(mxid)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class Typing extends ElemJS {
 | 
			
		||||
	constructor() {
 | 
			
		||||
		super(q("#c-typing"))
 | 
			
		||||
 | 
			
		||||
		this.typingUnsubscribe = null
 | 
			
		||||
 | 
			
		||||
		this.message = ejs("span")
 | 
			
		||||
		this.child(this.message)
 | 
			
		||||
 | 
			
		||||
		store.activeRoom.subscribe("changeSelf", this.changeRoom.bind(this))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	changeRoom() {
 | 
			
		||||
		if (this.typingUnsubscribe) {
 | 
			
		||||
			this.typingUnsubscribe()
 | 
			
		||||
			this.typingUnsubscribe = null
 | 
			
		||||
		}
 | 
			
		||||
		if (!store.activeRoom.exists()) return
 | 
			
		||||
		const room = store.activeRoom.value()
 | 
			
		||||
		this.typingUnsubscribe = room.timeline.typing.subscribe("changeSelf", this.render.bind(this))
 | 
			
		||||
		this.render()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		if (!store.activeRoom.exists()) return
 | 
			
		||||
		const room = store.activeRoom.value()
 | 
			
		||||
		let users = [...room.timeline.typing.value()]
 | 
			
		||||
		// don't show own typing status
 | 
			
		||||
		users = users.filter(u => u !== lsm.get("mx_user_id"))
 | 
			
		||||
		if (users.length === 0) {
 | 
			
		||||
			// nobody is typing
 | 
			
		||||
			this.removeClass("c-typing--typing")
 | 
			
		||||
		} else {
 | 
			
		||||
			let message = ""
 | 
			
		||||
			if (users.length === 1) {
 | 
			
		||||
				message = `${getMemberName(users[0])} is typing...`
 | 
			
		||||
			} else if (users.length <= maxUsers) {
 | 
			
		||||
				// feel free to rewrite this loop if you know a better way
 | 
			
		||||
				for (let i = 0; i < users.length; i++) {
 | 
			
		||||
					if (i < users.length-1) {
 | 
			
		||||
						message += `${getMemberName(users[i])}, `
 | 
			
		||||
					} else {
 | 
			
		||||
						message += `and ${getMemberName(users[i])} are typing...`
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			} else {
 | 
			
		||||
				message = `${users.length} people are typing...`
 | 
			
		||||
			}
 | 
			
		||||
			this.class("c-typing--typing")
 | 
			
		||||
			this.message.text(message)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
new Typing()
 | 
			
		||||
| 
						 | 
				
			
			@ -1,21 +1,32 @@
 | 
			
		|||
doctype html
 | 
			
		||||
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")
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -5,3 +5,5 @@ $mild: #393c42
 | 
			
		|||
$milder: #42454a
 | 
			
		||||
$divider: #4b4e54
 | 
			
		||||
$muted: #999
 | 
			
		||||
$link: #57bffd
 | 
			
		||||
$notify-highlight: #ffac4b
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										50
									
								
								src/sass/components/chat-banner.sass
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,50 @@
 | 
			
		|||
@use "../colors" as c
 | 
			
		||||
 | 
			
		||||
.c-chat-banner
 | 
			
		||||
  position: sticky
 | 
			
		||||
  z-index: 1
 | 
			
		||||
  top: 0
 | 
			
		||||
  left: 0
 | 
			
		||||
  right: 0
 | 
			
		||||
  margin-right: 12px
 | 
			
		||||
  outline-color: #000
 | 
			
		||||
  opacity: 0
 | 
			
		||||
  transform: translateY(-40px)
 | 
			
		||||
  transition: transform 0.2s ease, opacity 0.2s ease-out
 | 
			
		||||
 | 
			
		||||
  &--active
 | 
			
		||||
    opacity: 1
 | 
			
		||||
    transform: translateY(0px)
 | 
			
		||||
 | 
			
		||||
  &__inner
 | 
			
		||||
    display: grid
 | 
			
		||||
    grid-template-columns: 1fr auto
 | 
			
		||||
    background: c.$notify-highlight
 | 
			
		||||
    color: #000
 | 
			
		||||
    margin: 0px 12px
 | 
			
		||||
    padding: 0px 12px
 | 
			
		||||
    border-radius: 0px 0px 10px 10px
 | 
			
		||||
    line-height: 1
 | 
			
		||||
    box-shadow: 0px 5px 5px -2px rgba(0, 0, 0, 0.1)
 | 
			
		||||
    cursor: pointer
 | 
			
		||||
 | 
			
		||||
    &:hover
 | 
			
		||||
      box-shadow: 0px 5px 5px -2px rgba(0, 0, 0, 0.6)
 | 
			
		||||
 | 
			
		||||
  &__part
 | 
			
		||||
    padding: 6px 0px 8px
 | 
			
		||||
 | 
			
		||||
    &:hover
 | 
			
		||||
      text-decoration: underline
 | 
			
		||||
 | 
			
		||||
  &__part-inner
 | 
			
		||||
    display: block
 | 
			
		||||
    width: 100% // yes, really.
 | 
			
		||||
    text-align: left
 | 
			
		||||
 | 
			
		||||
  &__last
 | 
			
		||||
    margin-left: 8px
 | 
			
		||||
 | 
			
		||||
  &__last &__part-inner
 | 
			
		||||
    border-left: 1px solid #222
 | 
			
		||||
    padding-left: 8px
 | 
			
		||||
| 
						 | 
				
			
			@ -6,11 +6,14 @@
 | 
			
		|||
  -webkit-appearance: $value
 | 
			
		||||
 | 
			
		||||
.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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										1
									
								
								src/sass/components/highlighted-code.sass
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1 @@
 | 
			
		|||
@use "../../../node_modules/highlight.js/scss/obsidian"
 | 
			
		||||
| 
						 | 
				
			
			@ -1,6 +1,6 @@
 | 
			
		|||
@use "../colors" as c
 | 
			
		||||
 | 
			
		||||
.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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										42
									
								
								src/sass/components/read-marker.sass
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,42 @@
 | 
			
		|||
@use "../colors" as c
 | 
			
		||||
 | 
			
		||||
.c-read-marker
 | 
			
		||||
  display: none
 | 
			
		||||
  position: relative
 | 
			
		||||
 | 
			
		||||
  &--attached
 | 
			
		||||
    display: block
 | 
			
		||||
 | 
			
		||||
  &__inner
 | 
			
		||||
    position: absolute
 | 
			
		||||
    left: -64px
 | 
			
		||||
    right: 0px
 | 
			
		||||
    height: 2px
 | 
			
		||||
    top: 0px
 | 
			
		||||
    background-color: c.$notify-highlight
 | 
			
		||||
 | 
			
		||||
    @at-root .c-message:last-child &
 | 
			
		||||
      top: 11px
 | 
			
		||||
 | 
			
		||||
    @at-root .c-message-event &
 | 
			
		||||
      top: 7px
 | 
			
		||||
 | 
			
		||||
  &__text
 | 
			
		||||
    position: absolute
 | 
			
		||||
    right: -14px
 | 
			
		||||
    top: -9px
 | 
			
		||||
    display: flex
 | 
			
		||||
    align-items: center
 | 
			
		||||
    background-color: c.$notify-highlight
 | 
			
		||||
    color: #000
 | 
			
		||||
    font-size: 12px
 | 
			
		||||
    font-weight: 600
 | 
			
		||||
    line-height: 1
 | 
			
		||||
    padding: 4px
 | 
			
		||||
    border-radius: 5px
 | 
			
		||||
    text-transform: uppercase
 | 
			
		||||
 | 
			
		||||
  &__loading
 | 
			
		||||
    background-color: #000
 | 
			
		||||
    width: 10px
 | 
			
		||||
    height: 10px
 | 
			
		||||
| 
						 | 
				
			
			@ -43,3 +43,23 @@ $icon-padding: 8px
 | 
			
		|||
    white-space: nowrap
 | 
			
		||||
    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
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
							
								
								
									
										8
									
								
								src/sass/components/spoilers.sass
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,8 @@
 | 
			
		|||
.mx-spoiler
 | 
			
		||||
  color: #331911
 | 
			
		||||
  background-color:  #331911
 | 
			
		||||
  outline-color: #fff !important
 | 
			
		||||
  cursor: pointer
 | 
			
		||||
 | 
			
		||||
  &--shown
 | 
			
		||||
    color: inherit
 | 
			
		||||
							
								
								
									
										21
									
								
								src/sass/components/typing.sass
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,21 @@
 | 
			
		|||
@use "../colors" as c
 | 
			
		||||
 | 
			
		||||
.c-typing
 | 
			
		||||
  height: 39px
 | 
			
		||||
  background: c.$divider
 | 
			
		||||
  position: absolute
 | 
			
		||||
  right: 0
 | 
			
		||||
  left: 0
 | 
			
		||||
  top: 0
 | 
			
		||||
  z-index: 0
 | 
			
		||||
  margin: 20px
 | 
			
		||||
  border-radius: 8px
 | 
			
		||||
  padding: 0px 12px
 | 
			
		||||
  font-size: 14px
 | 
			
		||||
  line-height: 19px
 | 
			
		||||
  transform: translateY(0px)
 | 
			
		||||
  transition: transform 0.15s ease
 | 
			
		||||
  color: #fff
 | 
			
		||||
 | 
			
		||||
  &--typing
 | 
			
		||||
    transform: translateY(-21px)
 | 
			
		||||
							
								
								
									
										13
									
								
								src/sass/loading.sass
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,13 @@
 | 
			
		|||
@keyframes spin
 | 
			
		||||
  0%
 | 
			
		||||
    transform: rotate(0deg)
 | 
			
		||||
  100%
 | 
			
		||||
    transform: rotate(180deg)
 | 
			
		||||
 | 
			
		||||
.loading-icon
 | 
			
		||||
  display: inline-block
 | 
			
		||||
  background-color: #ccc
 | 
			
		||||
  width: 12px
 | 
			
		||||
  height: 12px
 | 
			
		||||
  margin-right: 6px
 | 
			
		||||
  animation: spin 0.7s infinite
 | 
			
		||||
							
								
								
									
										73
									
								
								src/sass/login.sass
									
										
									
									
									
										Normal file
									
								
							
							
						
						| 
						 | 
				
			
			@ -0,0 +1,73 @@
 | 
			
		|||
@use "./base"
 | 
			
		||||
@use "./loading.sass"
 | 
			
		||||
@use "./colors.sass" as c
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
.main
 | 
			
		||||
  justify-content: center
 | 
			
		||||
  align-items: center
 | 
			
		||||
 | 
			
		||||
.center-login-container
 | 
			
		||||
  display: flex
 | 
			
		||||
  flex-flow: column
 | 
			
		||||
  justify-content: center
 | 
			
		||||
  align-items: center
 | 
			
		||||
  width: min(100vw, 450px)
 | 
			
		||||
  padding: max(1rem,3vw) 2rem
 | 
			
		||||
  margin: 8px
 | 
			
		||||
  box-shadow: 0px 2px 10px c.$darkest
 | 
			
		||||
  background-color: c.$darker
 | 
			
		||||
  border-radius: 5px
 | 
			
		||||
 | 
			
		||||
.login-form
 | 
			
		||||
  align-items: center
 | 
			
		||||
  flex: 1 1 auto
 | 
			
		||||
  width: 100%
 | 
			
		||||
  display: flex
 | 
			
		||||
  justify-content: space-around
 | 
			
		||||
  flex-flow: column
 | 
			
		||||
 | 
			
		||||
.data-input
 | 
			
		||||
  width: 100%
 | 
			
		||||
 | 
			
		||||
.form-input-container
 | 
			
		||||
  width: 100%
 | 
			
		||||
  display: flex
 | 
			
		||||
  flex-direction: column
 | 
			
		||||
  margin: 1em 0
 | 
			
		||||
 | 
			
		||||
.form-feedback
 | 
			
		||||
  width: 100%
 | 
			
		||||
  margin: -0.5em 0 -0.8em
 | 
			
		||||
 | 
			
		||||
.form-error
 | 
			
		||||
  color: red
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
input, button
 | 
			
		||||
  font-family: inherit
 | 
			
		||||
  font-size: 17px
 | 
			
		||||
  background-color: c.$mild
 | 
			
		||||
  color: #eee
 | 
			
		||||
  width: 100%
 | 
			
		||||
  border-radius: 5px
 | 
			
		||||
  box-sizing: border-box
 | 
			
		||||
  transition: background-color 0.15s ease-out, border-color 0.15s ease-out
 | 
			
		||||
  padding: 4px 9px
 | 
			
		||||
  border: 0px
 | 
			
		||||
 | 
			
		||||
input[type="text"],input[type="password"]
 | 
			
		||||
  border: 3px solid transparent
 | 
			
		||||
  margin: 0.4em 0px
 | 
			
		||||
 | 
			
		||||
  &:hover, &:focus
 | 
			
		||||
    border-color: c.$milder
 | 
			
		||||
 | 
			
		||||
button, input[type="submit"]
 | 
			
		||||
  padding: 7px
 | 
			
		||||
 | 
			
		||||
  &:hover
 | 
			
		||||
    background-color: c.$milder
 | 
			
		||||
 | 
			
		||||
label
 | 
			
		||||
  font-size: 18px
 | 
			
		||||
| 
						 | 
				
			
			@ -1,7 +1,13 @@
 | 
			
		|||
@use "./base"
 | 
			
		||||
@use "./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"
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||