Compare commits

..

59 commits

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

Feel free to debate this.
2020-11-05 18:03:25 +13:00
b74f0cc0dd
Don't highlight very short code blocks 2020-11-05 17:57:27 +13:00
1aebc2c100
Only hint modules once
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2020-11-05 17:48:13 +13:00
017f30be65
Also format m.notice
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2020-11-05 17:39:21 +13:00
a7165fe633
Fix purify and highlight
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
- purify: apply target=_blank to links
- purify: remove ALLOWED_URI_REGEXP - this breaks external links in
  anchor elements
- purify: return a DOM fragment instead of a string
- postprocess: only highlight pre
- postprocess: remove nested code inside pre
- better style messages with css
2020-11-05 17:37:00 +13:00
8ba9d73b33
Small refactors
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
- "event" -> "eventData"
- create renderText method
- italics
2020-11-05 16:44:22 +13:00
9cf0952d3a
Change files to kebab-case
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/pr Build is failing
2020-11-05 16:34:10 +13:00
714147b980
Fix lazy loading cache
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2020-11-05 16:32:42 +13:00
ebf6e7ea78
Show proper user data in room list
All checks were successful
continuous-integration/drone/push Build is passing
2020-11-05 16:23:40 +13:00
Bad
1bf1712684 Fix dynamic import with relative paths
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2020-10-31 18:24:05 +01:00
Bad
0738ce4cb1 Rename dateFormatter.js to date-formatter.js
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2020-10-31 18:21:04 +01:00
Bad
20e94f05e7 Lazy load highlight.js
This significantly reduces the bundle size(over 1MiB!) but it also uses
some hacks to dynamically load browserify modules on runtime(see
lazy-load-modules.js
2020-10-31 18:17:34 +01:00
Bad
4d59b1a9ac Merge branch 'princess' into rich-messages
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2020-10-30 23:06:34 +01:00
Bad
20bacce068 Remove the simple event shorthand
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2020-10-29 11:31:08 +01:00
Bad
f80bf36991 Style fixes 2020-10-29 11:09:15 +01:00
Bad
217a815750 Merge branch 'princess' into rich-messages
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2020-10-29 11:02:51 +01:00
Bad
bd9623578f Add hljs and improve sanitization
Some checks failed
continuous-integration/drone/push Build is passing
continuous-integration/drone/pr Build is failing
2020-10-29 10:42:17 +01:00
Bad
c144d75c99 Remove debug console.logs 2020-10-29 10:38:12 +01:00
Bad
66ecf44048 Remove console.log from membership 2020-10-29 10:36:38 +01:00
Bad
e08b895694 Create a simple event shorthand
All checks were successful
continuous-integration/drone/pr Build is passing
continuous-integration/drone/push Build is passing
2020-10-26 23:16:47 +01:00
Bad
d983385e16 Fix compiler warnings 2020-10-26 22:58:38 +01:00
Bad
f46f9abe6e Improve rich text rendering to more closely match the recommendations from the spec 2020-10-26 22:55:54 +01:00
Bad
1a8427925c Add unknown memberships 2020-10-26 22:55:27 +01:00
a
098ea88f5d Rebase rich-messages on princess
All checks were successful
continuous-integration/drone/push Build is passing
2020-10-26 21:09:36 +01:00
72b42e7b26 Initial work on rich messages 2020-10-26 21:04:08 +01:00
0348fed18d Initial work on rich messages
All checks were successful
continuous-integration/drone/push Build is passing
2020-10-26 09:10:02 +01:00
60 changed files with 2581 additions and 948 deletions

View file

@ -6,6 +6,8 @@ steps:
- name: build
image: node:current-alpine3.12
commands:
- apk update
- apk add git
- npm install -D
- npm run rebuild

View file

@ -5,6 +5,10 @@ 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
@ -54,10 +58,6 @@ Carbon is currently _technically_ usable as a chat app, but is very
early in development. These important features still need to be
implemented:
- Unreads
- Chat history
- Typing indicators
- Formatting
- Emojis
- Reactions
- Encryption
@ -66,6 +66,9 @@ implemented:
- Pinned channels
- Mumble integration
For more information, see [issue
#10.](https://gitdab.com/cadence/Carbon/issues/10)
## The code
### Downloading a CI build
@ -74,12 +77,20 @@ 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 yourself
### 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
@ -94,4 +105,4 @@ 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`, since this applies too much caching.)
(Avoid `npx http-server`, it caches too much stuff.)

View file

@ -145,9 +145,11 @@ async function addJS(sourcePath, targetPath) {
await fs.promises.writeFile(pj(buildDir, targetPath), content)
}
async function addBundle(sourcePath, targetPath) {
async function addBundle(sourcePath, targetPath, module = false) {
let opts = {}
if (module) opts.standalone = sourcePath
const content = await new Promise(resolve => {
browserify()
browserify([], opts)
.add(pj(".", sourcePath))
.transform(file => {
let content = ""
@ -173,7 +175,6 @@ async function addBundle(sourcePath, targetPath) {
})
const writer = fs.promises.writeFile(pj(buildDir, targetPath), content)
staticFiles.set(sourcePath, `${targetPath}?static=${hash(content)}`)
runHint(sourcePath, content)
await writer
}
@ -287,6 +288,9 @@ async function addBabel(sourcePath, targetPath) {
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)
}

770
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -11,17 +11,19 @@
"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",
"tippy.js": "^6.2.7"
}
"sass": "^1.26.10"
},
"devDependencies": {}
}

35
spec.js
View file

@ -39,6 +39,41 @@ module.exports = [
source: "/assets/icons/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",

View file

@ -0,0 +1,94 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
viewBox="0 0 20 20"
version="1.1"
id="svg1826"
sodipodi:docname="call-accepted.svg"
width="20"
height="20"
inkscape:export-filename="/home/cloud/Code/Carbon/src/assets/icons/call-out-accepted.svg.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)">
<metadata
id="metadata1830">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title>free-icons-solid</dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1440"
inkscape:window-height="879"
id="namedview1828"
showgrid="true"
inkscape:snap-global="true"
inkscape:snap-bbox="false"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:bbox-nodes="true"
inkscape:bbox-paths="false"
inkscape:object-paths="false"
inkscape:snap-intersection-paths="true"
inkscape:snap-midpoints="false"
inkscape:snap-smooth-nodes="false"
showguides="true"
inkscape:zoom="32"
inkscape:cx="7.9954672"
inkscape:cy="9.9614234"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg1826">
<inkscape:grid
type="xygrid"
id="grid2773"
originx="-158"
originy="-1412" />
</sodipodi:namedview>
<defs
id="defs4">
<style
id="style2">.a{fill:#757575;}.b{fill:#b4b4b4;}.c{fill:#767676;}</style>
</defs>
<title
id="title6">free-icons-solid</title>
<path
inkscape:connector-curvature="0"
class="a"
d="m 15,13 h -3 c -0.460994,-2.45e-4 -1.000244,0.539006 -1,1 l 0.0029,0.66952 C 7.75933,14.711832 4.2952877,11.24779 4.3376001,8.004177 L 5,8 C 5.4609939,8.000244 6.0002445,7.4609939 6,7 V 4 C 6.0002445,3.5390061 5.4609939,2.9997555 5,3 H 3 C 2.5390061,2.9997555 1.9997555,3.5390061 2,4 v 4 c -4.15e-5,5.069836 3.9301639,9.000041 9,9 h 4 c 0.460994,2.45e-4 1.000245,-0.539006 1,-1 v -2 c 2.45e-4,-0.460994 -0.539006,-1.000245 -1,-1 z"
id="path226"
style="fill:#73d216;fill-opacity:1;stroke:none;stroke-width:0.99999994"
sodipodi:nodetypes="ccccccccccccccccc" />
<path
style="opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#73d216;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill"
d="m 9,7 c 2,0 3,1 3,3"
id="path3644"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cc" />
<path
sodipodi:nodetypes="cc"
inkscape:connector-curvature="0"
id="path3646"
d="m 9,4 c 4,0 6,2 6,6"
style="opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#73d216;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill" />
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

View file

@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
viewBox="0 0 20 20"
version="1.1"
id="svg1826"
sodipodi:docname="call-in.svg"
width="20"
height="20"
inkscape:export-filename="/home/cloud/Code/Carbon/src/assets/icons/call-out-accepted.svg.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)">
<metadata
id="metadata1830">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title>free-icons-solid</dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1440"
inkscape:window-height="879"
id="namedview1828"
showgrid="true"
inkscape:snap-global="true"
inkscape:snap-bbox="false"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:bbox-nodes="true"
inkscape:bbox-paths="false"
inkscape:object-paths="false"
inkscape:snap-intersection-paths="true"
inkscape:snap-midpoints="false"
inkscape:snap-smooth-nodes="false"
showguides="true"
inkscape:zoom="16"
inkscape:cx="9.4645773"
inkscape:cy="12.891863"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg1826">
<inkscape:grid
type="xygrid"
id="grid2773"
originx="-158"
originy="-1412" />
</sodipodi:namedview>
<defs
id="defs4">
<style
id="style2">.a{fill:#757575;}.b{fill:#b4b4b4;}.c{fill:#767676;}</style>
</defs>
<title
id="title6">free-icons-solid</title>
<path
inkscape:connector-curvature="0"
class="a"
d="m 15,15 h -3 c -0.460994,-2.45e-4 -1.000244,0.539006 -1,1 l 0.0029,0.66952 C 7.75933,16.711832 4.2952877,13.24779 4.3376001,10.004177 L 5,10 C 5.4609939,10.000244 6.0002445,9.4609939 6,9 V 6 C 6.0002445,5.5390061 5.4609939,4.9997555 5,5 H 3 C 2.5390061,4.9997555 1.9997555,5.5390061 2,6 v 4 c -4.15e-5,5.069836 3.9301639,9.000041 9,9 h 4 c 0.460994,2.45e-4 1.000245,-0.539006 1,-1 v -2 c 2.45e-4,-0.460994 -0.539006,-1.000245 -1,-1 z"
id="path226"
style="fill:#d591c6;fill-opacity:1;stroke:none;stroke-width:0.99999994"
sodipodi:nodetypes="ccccccccccccccccc" />
<g
id="g2813"
style="stroke:#d591c6;stroke-opacity:1"
transform="matrix(1,0,0,-1,0,13.004142)">
<path
sodipodi:nodetypes="ccc"
inkscape:connector-curvature="0"
id="path2807"
d="M 13,11 V 2 l -3,3"
style="opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#d591c6;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill" />
<path
sodipodi:nodetypes="cc"
inkscape:connector-curvature="0"
id="path2809"
d="m 13,2 3,3"
style="opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#d591c6;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View file

@ -0,0 +1,98 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
viewBox="0 0 20 20"
version="1.1"
id="svg1826"
sodipodi:docname="call-out.svg"
width="20"
height="20"
inkscape:export-filename="/home/cloud/Code/Carbon/src/assets/icons/call-out-accepted.svg.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)">
<metadata
id="metadata1830">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title>free-icons-solid</dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1440"
inkscape:window-height="879"
id="namedview1828"
showgrid="true"
inkscape:snap-global="true"
inkscape:snap-bbox="false"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:bbox-nodes="true"
inkscape:bbox-paths="false"
inkscape:object-paths="false"
inkscape:snap-intersection-paths="true"
inkscape:snap-midpoints="false"
inkscape:snap-smooth-nodes="false"
showguides="true"
inkscape:zoom="16"
inkscape:cx="9.4645773"
inkscape:cy="12.891863"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg1826">
<inkscape:grid
type="xygrid"
id="grid2773"
originx="-158"
originy="-1412" />
</sodipodi:namedview>
<defs
id="defs4">
<style
id="style2">.a{fill:#757575;}.b{fill:#b4b4b4;}.c{fill:#767676;}</style>
</defs>
<title
id="title6">free-icons-solid</title>
<path
inkscape:connector-curvature="0"
class="a"
d="m 15,15 h -3 c -0.460994,-2.45e-4 -1.000244,0.539006 -1,1 l 0.0029,0.66952 C 7.75933,16.711832 4.2952877,13.24779 4.3376001,10.004177 L 5,10 C 5.4609939,10.000244 6.0002445,9.4609939 6,9 V 6 C 6.0002445,5.5390061 5.4609939,4.9997555 5,5 H 3 C 2.5390061,4.9997555 1.9997555,5.5390061 2,6 v 4 c -4.15e-5,5.069836 3.9301639,9.000041 9,9 h 4 c 0.460994,2.45e-4 1.000245,-0.539006 1,-1 v -2 c 2.45e-4,-0.460994 -0.539006,-1.000245 -1,-1 z"
id="path226"
style="fill:#d591c6;fill-opacity:1;stroke:none;stroke-width:0.99999994"
sodipodi:nodetypes="ccccccccccccccccc" />
<g
id="g2813"
style="stroke:#d591c6;stroke-opacity:1">
<path
sodipodi:nodetypes="ccc"
inkscape:connector-curvature="0"
id="path2807"
d="M 13,11 V 2 l -3,3"
style="opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#d591c6;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill" />
<path
sodipodi:nodetypes="cc"
inkscape:connector-curvature="0"
id="path2809"
d="m 13,2 3,3"
style="opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#d591c6;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View file

@ -0,0 +1,98 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
viewBox="0 0 20 20"
version="1.1"
id="svg1826"
sodipodi:docname="call-rejected.svg"
width="20"
height="20"
inkscape:export-filename="/home/cloud/Code/Carbon/src/assets/icons/call-out-accepted.svg.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)">
<metadata
id="metadata1830">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title>free-icons-solid</dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1440"
inkscape:window-height="879"
id="namedview1828"
showgrid="true"
inkscape:snap-global="true"
inkscape:snap-bbox="false"
inkscape:snap-bbox-edge-midpoints="true"
inkscape:bbox-nodes="true"
inkscape:bbox-paths="false"
inkscape:object-paths="false"
inkscape:snap-intersection-paths="true"
inkscape:snap-midpoints="false"
inkscape:snap-smooth-nodes="false"
showguides="true"
inkscape:zoom="22.627417"
inkscape:cx="12.65156"
inkscape:cy="10.798274"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="svg1826">
<inkscape:grid
type="xygrid"
id="grid2773"
originx="-158"
originy="-1412" />
</sodipodi:namedview>
<defs
id="defs4">
<style
id="style2">.a{fill:#757575;}.b{fill:#b4b4b4;}.c{fill:#767676;}</style>
</defs>
<title
id="title6">free-icons-solid</title>
<path
inkscape:connector-curvature="0"
class="a"
d="m 15,13 h -3 c -0.460994,-2.45e-4 -1.000244,0.539006 -1,1 l 0.0029,0.66952 C 7.75933,14.711832 4.2952877,11.24779 4.3376001,8.004177 L 5,8 C 5.4609939,8.000244 6.0002445,7.4609939 6,7 V 4 C 6.0002445,3.5390061 5.4609939,2.9997555 5,3 H 3 C 2.5390061,2.9997555 1.9997555,3.5390061 2,4 v 4 c -4.15e-5,5.069836 3.9301639,9.000041 9,9 h 4 c 0.460994,2.45e-4 1.000245,-0.539006 1,-1 v -2 c 2.45e-4,-0.460994 -0.539006,-1.000245 -1,-1 z"
id="path226"
style="fill:#f43f3f;fill-opacity:1;stroke:none;stroke-width:0.99999994"
sodipodi:nodetypes="ccccccccccccccccc" />
<g
id="g2927"
style="stroke:#f43f3f;stroke-opacity:1">
<path
sodipodi:nodetypes="cc"
inkscape:connector-curvature="0"
id="path2872"
d="M 11,8 16,3"
style="opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#f43f3f;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill" />
<path
style="opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#f43f3f;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill"
d="M 16,8 11,3"
id="path2923"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cc" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View file

@ -0,0 +1,81 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="20"
height="20"
viewBox="0 0 5.2916665 5.2916668"
version="1.1"
id="svg27"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
sodipodi:docname="invite-event.svg">
<defs
id="defs21" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="16"
inkscape:cx="12.866591"
inkscape:cy="7.092849"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="true"
units="px"
inkscape:window-width="1440"
inkscape:window-height="879"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:snap-global="false"
inkscape:snap-bbox="true"
inkscape:bbox-nodes="true">
<inkscape:grid
type="xygrid"
id="grid26" />
</sodipodi:namedview>
<metadata
id="metadata24">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-291.70832)">
<path
sodipodi:type="star"
style="opacity:1;fill:#fce94f;fill-opacity:1;fill-rule:nonzero;stroke:none;stroke-width:0.50955456;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill"
id="path1026"
sodipodi:sides="4"
sodipodi:cx="2.5607762"
sodipodi:cy="294.50937"
sodipodi:r1="2.1649818"
sodipodi:r2="0.86599272"
sodipodi:arg1="0.78539816"
sodipodi:arg2="1.5707963"
inkscape:flatsided="false"
inkscape:rounded="0"
inkscape:randomized="0"
d="m 4.0916496,296.04024 -1.5308733,-0.66488 -1.5308734,0.66488 0.6648806,-1.53087 -0.6648806,-1.53087 1.5308733,0.66488 1.5308734,-0.66488 -0.6648806,1.53087 z"
transform="matrix(0.73526681,0.7333768,-0.7333768,0.73526681,216.88378,75.810398)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View file

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

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Before After
Before After

View file

@ -0,0 +1,80 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="20"
height="20"
viewBox="0 0 5.2916665 5.2916668"
version="1.1"
id="svg27"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
sodipodi:docname="leave-event.svg">
<defs
id="defs21" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="11.313708"
inkscape:cx="-4.2728481"
inkscape:cy="-2.1951295"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="true"
units="px"
inkscape:window-width="1440"
inkscape:window-height="879"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1">
<inkscape:grid
type="xygrid"
id="grid26" />
</sodipodi:namedview>
<metadata
id="metadata24">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-291.70832)">
<path
style="opacity:1;fill:#e2e2e2;fill-opacity:1;fill-rule:nonzero;stroke:#f43f3f;stroke-width:0.52916664;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="M 4.4979167,294.35416 H 0.79374997"
id="path28"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cc" />
<path
inkscape:connector-curvature="0"
id="path30"
d="m 2.1166667,293.03124 -1.32291673,1.32292"
style="opacity:1;fill:#e2e2e2;fill-opacity:1;fill-rule:nonzero;stroke:#f43f3f;stroke-width:0.52916664;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
sodipodi:nodetypes="cc" />
<path
sodipodi:nodetypes="cc"
style="opacity:1;fill:#e2e2e2;fill-opacity:1;fill-rule:nonzero;stroke:#f43f3f;stroke-width:0.52916664;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:stroke markers fill"
d="m 0.79374997,294.35416 1.32291673,1.32291"
id="path32"
inkscape:connector-curvature="0" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

View file

@ -0,0 +1,83 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="20"
height="20"
viewBox="0 0 5.2916665 5.2916668"
version="1.1"
id="svg27"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
sodipodi:docname="profile-event.svg">
<defs
id="defs21" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="16"
inkscape:cx="8.674554"
inkscape:cy="12.76461"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="true"
units="px"
inkscape:window-width="1440"
inkscape:window-height="879"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:snap-global="true"
inkscape:snap-bbox="true"
inkscape:bbox-nodes="true">
<inkscape:grid
type="xygrid"
id="grid26" />
</sodipodi:namedview>
<metadata
id="metadata24">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-291.70832)">
<path
style="opacity:1;fill:none;fill-opacity:1;fill-rule:nonzero;stroke:#729fcf;stroke-width:0.52916664;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill"
d="m 2.5761745,295.93007 c -0.6374174,0.0773 -1.2586148,-0.23706 -1.5739225,-0.79639 -0.31531561,-0.55933 -0.26263464,-1.25353 0.1334534,-1.75888 0.3960878,-0.50535 1.057602,-0.72235 1.6760616,-0.54979 0.6184669,0.17255 1.0674318,0.73341 1.3352208,1.26428"
id="path941"
inkscape:connector-curvature="0"
sodipodi:nodetypes="csscc" />
<path
style="opacity:1;fill:#729fcf;fill-opacity:1;fill-rule:nonzero;stroke:#729fcf;stroke-width:0.52916664;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill"
d="M 4.1469878,294.08929 3.0515179,293.89345"
id="path943"
inkscape:connector-curvature="0"
sodipodi:nodetypes="cc" />
<path
sodipodi:nodetypes="cc"
inkscape:connector-curvature="0"
id="path948"
d="m 4.2827903,292.91012 -0.1358025,1.17917"
style="opacity:1;fill:#729fcf;fill-opacity:1;fill-rule:nonzero;stroke:#729fcf;stroke-width:0.52916664;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;paint-order:markers stroke fill" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -41,7 +41,7 @@ html
| )
link(rel="stylesheet" type="text/css" href=getStatic("/sass/main.sass"))
script(type="module" src=getStatic("/js/main.js"))
body
body.show-focus
main.main
.c-groups
.c-groups__display#c-groups-display
@ -49,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
.c-typing#c-typing

View file

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

View file

@ -2,27 +2,92 @@ 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() {
@ -34,5 +99,12 @@ function fixHeight() {
function send(body) {
if (!store.activeRoom.exists()) return
if (!body.trim().length) return
return store.activeRoom.value().timeline.send(body)
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)
}

3
src/js/date-formatter.js Normal file
View file

@ -0,0 +1,3 @@
const dateFormatter = Intl.DateTimeFormat("default", {hour: "numeric", minute: "numeric", day: "numeric", month: "short", year: "numeric"})
module.exports = {dateFormatter}

67
src/js/events/call.js Normal file
View file

@ -0,0 +1,67 @@
const {UngroupableEvent} = require("./event")
const {ejs} = require("../basic")
const lsm = require("../lsm")
const {extractDisplayName, resolveMxc, extractLocalpart} = require("../functions")
class CallEvent extends UngroupableEvent {
constructor(data) {
super(data)
this.class("c-message-event")
this.senderName = extractLocalpart(this.data.sender)
this.render()
}
renderInner(iconURL, elements) {
this.clearChildren()
this.child(
ejs("div").class("c-message-event__inner").child(
iconURL ? ejs("img").class("c-message-event__icon").attribute("width", "20").attribute("height", "20").attribute("src", iconURL) : "",
...elements
)
)
super.render()
}
}
class CallInviteEvent extends CallEvent {
static canRender(eventData) {
return eventData.type === "m.call.invite"
}
render() {
const icon = this.data.sender === lsm.get("mx_user_id") ? "static/call-out.svg" : "static/call-in.svg"
this.renderInner(icon, [
this.senderName,
" started a VOIP call, but Carbon doesn't support VOIP calls"
])
}
}
class CallAnswerEvent extends CallEvent {
static canRender(eventData) {
return eventData.type === "m.call.answer"
}
render() {
this.renderInner("static/call-accepted.svg", [
this.senderName,
" answered the call"
])
}
}
class CallHangupEvent extends CallEvent {
static canRender(eventData) {
return eventData.type === "m.call.hangup"
}
render() {
const reason = this.data.content.reason === "invite_timeout" ? "missed the call" : "hung up the call"
this.renderInner("static/call-rejected.svg", [
this.senderName,
" " + reason
])
}
}
module.exports = [CallInviteEvent, CallAnswerEvent, CallHangupEvent]

View file

@ -0,0 +1,36 @@
const {ElemJS} = require("../basic")
const {lazyLoad} = require("../lazy-load-module")
class HighlightedCode extends ElemJS {
constructor(element) {
super(element)
if (this.element.tagName === "PRE" && this.element.children.length === 1 && this.element.children[0].tagName === "CODE") {
// we shouldn't nest <code> inside <pre>. put the text in <pre> directly.
const code = this.element.children[0]
this.clearChildren()
while (code.firstChild) {
this.element.appendChild(code.firstChild)
}
}
let shouldHighlight = (
// if there are child _elements_, it's already formatted, we shouldn't mess that up
this.element.children.length === 0
/*
no need to highlight very short code blocks:
- content inside might not be code, some users still use code blocks
for plaintext quotes
- language detection will almost certainly be incorrect
- even if it's code and the language is detected, the user will
be able to mentally format small amounts of code themselves
feel free to change the threshold number
*/
&& this.element.textContent.length > 80
)
if (shouldHighlight) {
lazyLoad("https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@10/build/highlight.min.js").then(hljs => hljs.highlightBlock(this.element))
}
}
}
module.exports = {HighlightedCode}

View file

@ -0,0 +1,18 @@
const {GroupableEvent} = require("./event")
const {ejs} = require("../basic")
class EncryptedMessage extends GroupableEvent {
render() {
this.clearChildren()
this.child(
ejs("i").text("Carbon cannot render encrypted messages yet")
)
super.render()
}
static canRender(eventData) {
return eventData.type === "m.room.encrypted"
}
}
module.exports = [EncryptedMessage]

72
src/js/events/event.js Normal file
View file

@ -0,0 +1,72 @@
const {ElemJS, ejs} = require("../basic")
const {dateFormatter} = require("../date-formatter")
const {SubscribeSet} = require("../store/subscribe_set.js")
class MatrixEvent extends ElemJS {
constructor(data) {
super("div")
this.data = null
this.group = null
this.editedAt = null
this.readBy = new SubscribeSet()
this.update(data)
}
// predicates
canGroup() {
return false
}
// operations
setGroup(group) {
this.group = group
}
setEdited(time) {
this.editedAt = time
this.render()
}
update(data) {
this.data = data
this.render()
}
removeEvent() {
if (this.group) this.group.removeEvent(this)
else this.remove()
}
render() {
this.element.classList[this.data.pending ? "add" : "remove"]("c-message--pending")
if (this.editedAt) {
this.child(ejs("span").class("c-message__edited").text("(edited)").attribute("title", "at " + dateFormatter.format(this.editedAt)))
}
return this
}
static canRender(eventData) {
return false
}
}
class GroupableEvent extends MatrixEvent {
constructor(data) {
super(data)
this.class("c-message")
}
canGroup() {
return true
}
}
class UngroupableEvent extends MatrixEvent {
}
module.exports = {
GroupableEvent,
UngroupableEvent
}

18
src/js/events/hidden.js Normal file
View file

@ -0,0 +1,18 @@
const {UngroupableEvent} = require("./event")
class HiddenEvent extends UngroupableEvent {
constructor(data) {
super(data)
this.class("c-hidden-event")
this.clearChildren()
}
static canRender(eventData) {
return ["m.reaction", "m.call.candidates"].includes(eventData.type)
}
render() {
}
}
module.exports = [HiddenEvent]

48
src/js/events/image.js Normal file
View file

@ -0,0 +1,48 @@
const {ejs, ElemJS} = require("../basic")
const {resolveMxc} = require("../functions")
const {GroupableEvent} = require("./event")
class Image extends GroupableEvent {
render() {
this.clearChildren()
this.class("c-message--media")
const image = (
ejs("img")
.class("c-message__image")
.attribute("src", resolveMxc(this.data.content.url))
)
const info = this.data.content.info
if (info && info.w && info.h) {
image.attribute("width", info.w)
image.attribute("height", info.h)
}
const wrapper = ejs("div").class("c-media__wrapper").child(
image
)
if (this.data.content.body && this.data.content.body.startsWith("SPOILER")) {
wrapper.attribute("tabindex", 0)
wrapper.class("c-media--spoiler")
const wall = ejs("div").class("c-media__spoiler").text("Spoiler")
wrapper.child(wall)
const toggle = () => {
wrapper.element.classList.toggle("c-media--shown")
}
wrapper.on("click", toggle)
wrapper.on("keydown", event => {
if (event.key === "Enter") toggle()
})
}
this.child(wrapper)
super.render()
}
static canRender(event) {
return event.type === "m.room.message" && event.content.msgtype === "m.image"
}
canGroup() {
return true
}
}
module.exports = [Image]

127
src/js/events/membership.js Normal file
View file

@ -0,0 +1,127 @@
const {UngroupableEvent} = require("./event")
const {ejs} = require("../basic")
const {extractDisplayName, resolveMxc, extractLocalpart} = require("../functions")
class MembershipEvent extends UngroupableEvent {
constructor(data) {
super(data)
this.class("c-message-event")
this.senderName = extractDisplayName(data)
if (data.content.avatar_url) {
this.smallAvatar = ejs("img")
.attribute("width", "32")
.attribute("height", "32")
.attribute("src", resolveMxc(data.content.avatar_url, 32, "crop"))
.class("c-message-event__avatar")
} else {
this.smallAvatar = ""
}
this.render()
}
static canRender(eventData) {
return eventData.type === "m.room.member"
}
renderInner(iconURL, elements) {
this.clearChildren()
this.child(
ejs("div").class("c-message-event__inner").child(
iconURL ? ejs("img").class("c-message-event__icon").attribute("width", "20").attribute("height", "20").attribute("src", iconURL) : "",
...elements
)
)
super.render()
}
}
class JoinedEvent extends MembershipEvent {
static canRender(eventData) {
return super.canRender(eventData) && eventData.content.membership === "join"
}
render() {
const changes = []
const prev = this.data.unsigned.prev_content
if (prev && prev.membership === "join") {
if (prev.avatar_url !== this.data.content.avatar_url) {
changes.push("changed their avatar")
}
if (prev.displayname !== this.data.content.displayname) {
changes.push(`changed their display name (was ${this.data.unsigned.prev_content.displayname})`)
}
}
let message
let iconURL
if (changes.length) {
message = " " + changes.join(", ")
iconURL = "static/profile-event.svg"
} else {
message = " joined the room"
iconURL = "static/join-event.svg"
}
this.renderInner(iconURL, [
this.smallAvatar,
this.senderName,
message
])
}
}
class InvitedEvent extends MembershipEvent {
static canRender(eventData) {
return super.canRender(eventData) && eventData.content.membership === "invite"
}
render() {
this.renderInner("static/invite-event.svg", [
this.smallAvatar,
`${extractLocalpart(this.data.sender)} invited ${this.data.state_key}` // full mxid for clarity
])
}
}
class LeaveEvent extends MembershipEvent {
static canRender(eventData) {
return super.canRender(eventData) && eventData.content.membership === "leave"
}
render() {
this.renderInner("static/leave-event.svg", [
this.smallAvatar,
this.senderName,
" left the room"
])
}
}
class BanEvent extends MembershipEvent {
static canRender(eventData) {
return super.canRender(eventData) && eventData.content.membership === "ban"
}
render() {
let message =
` left (banned by ${this.data.sender}`
+ (this.data.content.reason ? `, reason: ${this.data.content.reason}` : "")
+ ")"
this.renderInner("static/leave-event.svg", [
this.smallAvatar,
this.senderName,
message
])
}
}
class UnknownMembership extends MembershipEvent {
render() {
this.renderInner("", [
this.smallAvatar,
this.senderName,
ejs("i").text(" unknown membership event")
])
}
}
module.exports = [JoinedEvent, InvitedEvent, LeaveEvent, BanEvent, UnknownMembership]

164
src/js/events/message.js Normal file
View file

@ -0,0 +1,164 @@
const {ejs, ElemJS} = require("../basic")
const {HighlightedCode} = require("./components")
const DOMPurify = require("dompurify")
const {resolveMxc} = require("../functions")
const {GroupableEvent} = require("./event")
const purifier = DOMPurify()
purifier.addHook("uponSanitizeAttribute", (node, hookevent, config) => {
// If purifier already rejected an attribute there is no point in checking it
if (hookevent.keepAttr === false) return;
const allowedElementAttributes = {
"FONT": ["data-mx-bg-color", "data-mx-color", "color"],
"SPAN": ["data-mx-bg-color", "data-mx-color", "data-mx-spoiler"],
"A": ["name", "target", "href"],
"IMG": ["width", "height", "alt", "title", "src", "data-mx-emoticon"],
"OL": ["start"],
"CODE": ["class"],
}
const allowedAttributes = allowedElementAttributes[node.tagName] || []
hookevent.keepAttr = allowedAttributes.indexOf(hookevent.attrName) > -1
})
purifier.addHook("uponSanitizeElement", (node, hookevent, config) => {
// Remove bad classes from our code element
if (node.tagName === "CODE") {
node.classList.forEach(c => {
if (!c.startsWith("language-")) {
node.classList.remove(c)
}
})
}
if (node.tagName === "A") {
node.setAttribute("rel", "noopener") // prevent the opening page from accessing carbon
node.setAttribute("target", "_blank") // open in a new tab instead of replacing carbon
}
return node
})
function cleanHTML(html) {
const config = {
ALLOWED_TAGS: [
"font", "del", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "p",
"a", "ul", "ol", "sup", "sub", "li", "b", "i", "u", "strong", "em",
"strike", "code", "hr", "br", "div", "table", "thead", "tbody", "tr",
"th", "td", "caption", "pre", "span", "img",
// matrix tags
"mx-reply"
],
// In case we mess up in the uponSanitizeAttribute hook
ALLOWED_ATTR: [
"color", "name", "target", "href", "width", "height", "alt", "title",
"src", "start", "class", "noreferrer", "noopener",
// matrix attrs
"data-mx-emoticon", "data-mx-bg-color", "data-mx-color", "data-mx-spoiler"
],
// Return a DOM fragment instead of a string, avoids potential future mutation XSS
// should also be faster than the browser parsing HTML twice
// https://research.securitum.com/mutation-xss-via-mathml-mutation-dompurify-2-0-17-bypass/
RETURN_DOM_FRAGMENT: true,
RETURN_DOM_IMPORT: true
}
return purifier.sanitize(html, config)
}
// Here we put all the processing of the messages that isn't as likely to potentially lead to security issues
function postProcessElements(element) {
element.querySelectorAll("pre").forEach(n => {
new HighlightedCode(n)
})
element.querySelectorAll("img").forEach(n => {
let src = n.getAttribute("src")
if (src) src = resolveMxc(src)
n.setAttribute("src", src)
})
element.querySelectorAll("font, span").forEach(n => {
const color = n.getAttribute("data-mx-color") || n.getAttribute("color")
const bgColor = n.getAttribute("data-mx-bg-color")
if (color) n.style.color = color
if (bgColor) n.style.backgroundColor = bgColor
})
element.querySelectorAll("[data-mx-spoiler]").forEach(spoiler => {
spoiler.classList.add("mx-spoiler")
spoiler.setAttribute("tabindex", 0)
function toggle() {
spoiler.classList.toggle("mx-spoiler--shown")
}
spoiler.addEventListener("click", toggle)
spoiler.addEventListener("keydown", event => {
if (event.key === "Enter") toggle()
})
})
}
class HTMLMessage extends GroupableEvent {
render() {
this.clearChildren()
let html = this.data.content.formatted_body
const fragment = cleanHTML(html)
postProcessElements(fragment)
this.child(ejs(fragment))
super.render()
}
static canRender(event) {
const content = event.content
return (
event.type === "m.room.message"
&& (content.msgtype === "m.text" || content.msgtype === "m.notice")
&& content.format === "org.matrix.custom.html"
&& content.formatted_body
)
}
}
function autoLinkText(text) {
const fragment = ejs(new DocumentFragment())
let lastIndex = 0
text.replace(/https?:\/\/(?:[A-Za-z-]+\.)+[A-Za-z]{1,10}(?::[0-9]{1,6})?(?:\/[^ ]*)?/g, (url, index) => {
// add text before URL
fragment.addText(text.slice(lastIndex, index))
// add URL
fragment.child(
ejs("a")
.attribute("target", "_blank")
.attribute("noopener", "")
.attribute("href", url)
.addText(url)
)
// update state
lastIndex = index + url.length
})
// add final text
fragment.addText(text.slice(lastIndex))
return fragment
}
class TextMessage extends GroupableEvent {
render() {
this.clearChildren()
this.class("c-message--plain")
const fragment = autoLinkText(this.data.content.body)
this.child(fragment)
super.render()
}
static canRender(event) {
return event.type === "m.room.message"
}
}
module.exports = [HTMLMessage, TextMessage]

View file

@ -0,0 +1,24 @@
const imageEvent = require("./image")
const messageEvent = require("./message")
const encryptedEvent = require("./encrypted")
const membershipEvent = require("./membership")
const unknownEvent = require("./unknown")
const callEvent = require("./call")
const hiddenEvent = require("./hidden")
const events = [
...imageEvent,
...messageEvent,
...encryptedEvent,
...membershipEvent,
...callEvent,
...hiddenEvent,
...unknownEvent,
]
function renderEvent(eventData) {
const constructor = events.find(e => e.canRender(eventData))
return new constructor(eventData)
}
module.exports = {renderEvent}

19
src/js/events/unknown.js Normal file
View file

@ -0,0 +1,19 @@
const {GroupableEvent} = require("./event")
const {ejs} = require("../basic")
class UnknownEvent extends GroupableEvent {
static canRender() {
return true
}
render() {
this.clearChildren()
this.child(
ejs("i").text(`Unknown event of type ${this.data.type}`)
)
super.render()
}
}
module.exports = [UnknownEvent]

11
src/js/focus.js Normal file
View file

@ -0,0 +1,11 @@
document.body.classList.remove("show-focus")
document.addEventListener("mousedown", () => {
document.body.classList.remove("show-focus")
})
document.addEventListener("keydown", event => {
if (event.key === "Tab") {
document.body.classList.add("show-focus")
}
})

View file

@ -1,7 +1,9 @@
const lsm = require("./lsm.js")
function resolveMxc(url, size, method) {
let [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}`
@ -10,4 +12,28 @@ function resolveMxc(url, size, method) {
}
}
module.exports = {resolveMxc}
function extractLocalpart(mxid) {
// try to extract the localpart from the mxid
let match = mxid.match(/^@([^:]+):/)
if (match) {
return match[1]
}
// localpart extraction failed, use the whole mxid
return mxid
}
function extractDisplayName(stateEvent) {
const mxid = stateEvent.state_key
// see if a display name is set
if (stateEvent.content.displayname) {
return stateEvent.content.displayname
}
// fall back to the mxid
return extractLocalpart(mxid)
}
module.exports = {
resolveMxc,
extractLocalpart,
extractDisplayName
}

View file

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

View file

@ -1,78 +1,36 @@
const tippy = require("tippy.js");
const {q, ElemJS, ejs} = require("./basic.js")
const {S_RE_DOMAIN, S_RE_IPV6, S_RE_IPV4} = require("./re.js")
const password = q("#password")
// A regex matching a lossy MXID
// Groups:
// 1: username/localpart
// MAYBE WITH
// 2: hostname/serverpart
// 3: domain
// OR
// 4: IP address
// 5: IPv4 address
// OR
// 6: IPv6 address
// MAYBE WITH
// 7: port number
const RE_LOSSY_MXID = new RegExp(`^@?([a-z0-9._=-]+?)(?::((?:(${S_RE_DOMAIN})|((${S_RE_IPV4})|(\\[${S_RE_IPV6}])))(?::(\\d+))?))?$`)
window.RE_LOSSY_MXID = RE_LOSSY_MXID
const homeserver = q("#homeserver")
class Username extends ElemJS {
constructor(homeserver) {
constructor() {
super(q("#username"))
this.homeserver = homeserver;
this.on("change", this.updateServer.bind(this))
}
isValid() {
return !!this.element.value.match(RE_LOSSY_MXID)
return !!this.element.value.match(/^@?[a-z0-9._=\/-]+(?::[a-zA-Z0-9.:\[\]-]+)?$/)
}
getUsername() {
return this.element.value.match(RE_LOSSY_MXID)[1]
return this.element.value.match(/^@?([a-z0-9._=\/-]+)/)[1]
}
getServer() {
const server = this.element.value.match(RE_LOSSY_MXID)
if (server && server[2]) return server[2]
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()) this.homeserver.suggest(this.getServer())
if (this.getServer()) homeserver.value = this.getServer()
}
}
class Homeserver extends ElemJS {
constructor() {
super(q("#homeserver"));
this.tippy = tippy.default(q(".homeserver-question"), {
content: q("#homeserver-popup-template").innerHTML,
allowHTML: true,
interactive: true,
interactiveBorder: 10,
trigger: "focus mouseenter",
theme: "carbon",
arrow: tippy.roundArrow,
})
}
suggest(value) {
this.element.placeholder = value
}
getServer() {
return this.element.value || this.element.placeholder;
}
}
const username = new Username()
class Feedback extends ElemJS {
constructor() {
@ -96,20 +54,19 @@ class Feedback extends ElemJS {
this.removeClass("form-feedback")
this.removeClass("form-error")
if (content) this.class("form-feedback")
if (isError) this.class("form-error")
if(isError) this.class("form-error")
this.messageSpan.text(content)
}
}
const feedback = new Feedback()
class Form extends ElemJS {
constructor(username, feedback, homeserver) {
constructor() {
super(q("#form"))
this.processing = false
this.username = username
this.feedback = feedback
this.homeserver = homeserver
this.on("submit", this.submit.bind(this))
}
@ -117,13 +74,13 @@ class Form extends ElemJS {
async submit() {
if (this.processing) return
this.processing = true
if (!this.username.isValid()) return this.cancel("Username is not valid.")
if (!username.isValid()) return this.cancel("Username is not valid.")
// Resolve homeserver address
let domain
try {
domain = await this.findHomeserver(this.homeserver.getServer())
} catch (e) {
domain = await this.findHomeserver(homeserver.value)
} catch(e) {
return this.cancel(e.message)
}
@ -133,7 +90,7 @@ class Form extends ElemJS {
method: "POST",
body: JSON.stringify({
type: "m.login.password",
user: this.username.getUsername(),
user: username.getUsername(),
password: password.value
})
}).then(res => res.json())
@ -177,7 +134,7 @@ class Form extends ElemJS {
const versions = await versionsReq.json()
if (Array.isArray(versions.versions)) return address
}
} catch (e) {}
} 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 => {
@ -196,21 +153,15 @@ class Form extends ElemJS {
}
status(message) {
this.feedback.setLoading(true)
this.feedback.message(message)
feedback.setLoading(true)
feedback.message(message)
}
cancel(message) {
this.processing = false
this.feedback.setLoading(false)
this.feedback.message(message, true)
feedback.setLoading(false)
feedback.message(message, true)
}
}
const homeserver = new Homeserver()
const username = new Username(homeserver)
const feedback = new Feedback()
const form = new Form(username, feedback, homeserver)
const form = new Form()

View file

@ -1,8 +1,10 @@
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/")

View file

@ -1,38 +0,0 @@
// A valid internet domain, according to https://stackoverflow.com/a/20046959 (cleaned)
const S_RE_DOMAIN = "(?:[a-zA-Z]|[a-zA-Z][a-zA-Z]|[a-zA-Z]\\d|\\d[a-zA-Z]|[a-zA-Z0-9][a-zA-Z0-9-_]{1,61}[a-zA-Z0-9])\\.(?:[a-zA-Z]{2,6}|[a-zA-Z0-9-]{2,30}\\.[a-zA-Z]{2,3})"
// A valid ipv4 address, one that doesn't check for valid numbers (e.g. not 999) and one that does
// const S_RE_IPV4_NO_CHECK = "(?:(?:\\d{1,3}\\.){3}\\d{1,3})"
const S_RE_IPV4_HAS_CHECK = "(?:(?:25[0-5]|(?:2[0-4]|1{0,1}\\d){0,1}\\d)\\.){3}(?:25[0-5]|(?:2[0-4]|1{0,1}\\d){0,1}\\d)"
const S_RE_IPV6_SEG = "[a-fA-F\\d]{1,4}"
// Yes, this is an ipv6 address.
const S_RE_IPV6 = `
(?:
(?:${S_RE_IPV6_SEG}:){7}(?:${S_RE_IPV6_SEG}|:)|
(?:${S_RE_IPV6_SEG}:){6}(?:${S_RE_IPV4_HAS_CHECK}|:${S_RE_IPV6_SEG}|:)|
(?:${S_RE_IPV6_SEG}:){5}(?::${S_RE_IPV4_HAS_CHECK}|(?::${S_RE_IPV6_SEG}){1,2}|:)|
(?:${S_RE_IPV6_SEG}:){4}(?:(?::${S_RE_IPV6_SEG}){0,1}:${S_RE_IPV4_HAS_CHECK}|(?::${S_RE_IPV6_SEG}){1,3}|:)|
(?:${S_RE_IPV6_SEG}:){3}(?:(?::${S_RE_IPV6_SEG}){0,2}:${S_RE_IPV4_HAS_CHECK}|(?::${S_RE_IPV6_SEG}){1,4}|:)|
(?:${S_RE_IPV6_SEG}:){2}(?:(?::${S_RE_IPV6_SEG}){0,3}:${S_RE_IPV4_HAS_CHECK}|(?::${S_RE_IPV6_SEG}){1,5}|:)|
(?:${S_RE_IPV6_SEG}:){1}(?:(?::${S_RE_IPV6_SEG}){0,4}:${S_RE_IPV4_HAS_CHECK}|(?::${S_RE_IPV6_SEG}){1,6}|:)|
(?::(?:(?::${S_RE_IPV6_SEG}){0,5}:${S_RE_IPV4_HAS_CHECK}|(?::${S_RE_IPV6_SEG}){1,7}|:))
)(?:%[0-9a-zA-Z]{1,})?`
.replace(/\s*\/\/.*$/gm, '')
.replace(/\n/g, '')
.trim();
const RE_DOMAIN_EXACT = new RegExp(`^${S_RE_DOMAIN}$`)
const RE_IPV4_EXACT = new RegExp(`^${S_RE_IPV4_HAS_CHECK}$`)
const RE_IPV6_EXACT = new RegExp(`^${S_RE_IPV6}$`)
const RE_IP_ADDR_EXACT = new RegExp(`^${S_RE_IPV6}|${S_RE_IPV4_HAS_CHECK}$`)
module.exports = {
S_RE_DOMAIN,
S_RE_IPV6,
S_RE_IPV4: S_RE_IPV4_HAS_CHECK,
RE_DOMAIN_EXACT,
RE_IPV4_EXACT,
RE_IPV6_EXACT,
RE_IP_ADDR_EXACT
}

149
src/js/read-marker.js Normal file
View file

@ -0,0 +1,149 @@
const {ElemJS, ejs, q} = require("./basic.js")
const {store} = require("./store/store.js")
const lsm = require("./lsm.js")
function markFullyRead(roomID, eventID) {
return fetch(`${lsm.get("domain")}/_matrix/client/r0/rooms/${roomID}/read_markers?access_token=${lsm.get("access_token")}`, {
method: "POST",
body: JSON.stringify({
"m.fully_read": eventID,
"m.read": eventID
})
})
}
class ReadBanner extends ElemJS {
constructor() {
super(q("#c-chat-banner"))
this.newMessages = ejs("span")
this.child(
ejs("div").class("c-chat-banner__inner").child(
ejs("button").class("c-chat-banner__part").on("click", this.jumpTo.bind(this)).child(
ejs("div").class("c-chat-banner__part-inner")
.child(this.newMessages)
.addText(" new messages")
),
ejs("button").class("c-chat-banner__part", "c-chat-banner__last").on("click", this.markRead.bind(this)).child(
ejs("div").class("c-chat-banner__part-inner").text("Mark as read")
)
)
)
store.activeRoom.subscribe("changeSelf", this.render.bind(this))
store.notificationsChange.subscribe("changeSelf", this.render.bind(this))
this.render()
}
async jumpTo() {
if (!store.activeRoom.exists()) return
const timeline = store.activeRoom.value().timeline
const readMarker = timeline.readMarker
while (true) {
if (readMarker.attached) {
readMarker.element.scrollIntoView({behavior: "smooth", block: "center"})
return
} else {
q("#c-chat-messages").scrollTo({
top: 0,
left: 0,
behavior: "smooth"
})
await new Promise(resolve => {
const unsubscribe = timeline.subscribe("afterScrollbackLoad", () => {
unsubscribe()
resolve()
})
})
}
}
}
markRead() {
if (!store.activeRoom.exists()) return
const timeline = store.activeRoom.value().timeline
markFullyRead(timeline.id, timeline.latestEventID)
}
render() {
let count = 0
if (store.activeRoom.exists()) {
count = store.activeRoom.value().number.state.unreads
}
if (count !== 0) {
this.newMessages.text(count)
this.class("c-chat-banner--active")
} else {
this.removeClass("c-chat-banner--active")
}
}
}
const readBanner = new ReadBanner()
class ReadMarker extends ElemJS {
constructor(timeline) {
super("div")
this.class("c-read-marker")
this.loadingIcon = ejs("div")
.class("c-read-marker__loading", "loading-icon")
.style("display", "none")
this.child(
ejs("div").class("c-read-marker__inner").child(
ejs("div").class("c-read-marker__text").child(this.loadingIcon).addText("New")
)
)
let processing = false
const observer = new IntersectionObserver(entries => {
const entry = entries[0]
if (!entry.isIntersecting) return
if (processing) return
processing = true
this.loadingIcon.style("display", "")
markFullyRead(this.timeline.id, this.timeline.latestEventID).then(() => {
this.loadingIcon.style("display", "none")
processing = false
})
}, {
root: document.getElementById("c-chat-messages"),
rootMargin: "-80px 0px 0px 0px", // marker must be this distance inside the top of the screen to be counted as read
threshold: 0.01
})
observer.observe(this.element)
this.attached = false
this.timeline = timeline
this.timeline.userReads.get(lsm.get("mx_user_id")).subscribe("changeSelf", (_, eventID) => {
// read marker updated, attach to it
const event = this.timeline.map.get(eventID)
this.attach(event)
})
this.timeline.subscribe("afterChange", () => {
// timeline has new events, attach to last read one
const eventID = this.timeline.userReads.get(lsm.get("mx_user_id")).value()
const event = this.timeline.map.get(eventID)
this.attach(event)
})
}
attach(event) {
if (event && event.data.origin_server_ts !== this.timeline.latest) {
this.class("c-read-marker--attached")
event.element.insertAdjacentElement("beforeend", this.element)
this.attached = true
} else {
this.removeClass("c-read-marker--attached")
this.attached = false
}
if (store.activeRoom.value() === this.timeline.room) {
readBanner.render()
}
}
}
module.exports = {
ReadMarker,
readBanner,
markFullyRead
}

View file

@ -4,7 +4,7 @@ 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} = require("./functions.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,21 +168,37 @@ 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)
}
}
@ -105,9 +214,9 @@ class Room extends ElemJS {
return canonicalAlias.content.alias
}
// if the room has no alias, use the names of its members ("heroes")
const users = this.data.summary["m.heroes"]
const users = this.getHeroes()
if (users && users.length) {
const usernames = users.map(u => (u.match(/^@([^:]+):/) || [])[1] || u)
const usernames = users.map(mxid => this.getMemberName(mxid))
return usernames.join(", ")
}
// the room is empty
@ -115,6 +224,7 @@ class Room extends ElemJS {
}
getIcon() {
// if the room has a normal avatar
const avatar = this.data.state.events.find(e => e.type === "m.room.avatar")
if (avatar) {
const url = avatar.content.url || avatar.content.avatar_url
@ -122,6 +232,15 @@ class Room extends ElemJS {
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
}
@ -155,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")
@ -174,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()
}
@ -234,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()

View file

@ -9,7 +9,8 @@ const store = {
directs: new SubscribeSet(),
activeGroup: new SubscribeValue(),
activeRoom: new SubscribeValue(),
newEvents: new Subscribable()
newEvents: new Subscribable(),
notificationsChange: new Subscribable()
}
window.store = store

View file

@ -20,6 +20,8 @@ class Subscribable {
} else {
throw new Error(`Cannot subscribe to non-existent event ${event}, available events are: ${Object.keys(this.events).join(", ")}`)
}
// return a function we can call to easily unsubscribe
return () => this.unsubscribe(event, callback)
}
unsubscribe(event, callback) {

View file

@ -1,13 +1,22 @@
const {Subscribable} = require("./subscribable.js")
const {SubscribeValue} = require("./subscribe_value.js")
class SubscribeMap extends Subscribable {
constructor() {
constructor(inner) {
super()
this.inner = inner
Object.assign(this.events, {
addItem: [],
editItem: [],
deleteItem: [],
changeItem: [],
removeItem: []
askSet: []
})
Object.assign(this.eventDeps, {
addItem: ["changeItem"],
editItem: ["changeItem"],
deleteItem: ["changeItem"],
changeItem: [],
askSet: []
})
this.map = new Map()
}
@ -20,22 +29,46 @@ class SubscribeMap extends Subscribable {
if (this.map.has(key)) {
return this.map.get(key)
} else {
this.map.set(key, new SubscribeValue())
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)
this.broadcast("changeItem", key)
if (exists) {
this.broadcast("editItem", key)
} else {
s = new SubscribeValue().set(value)
this.broadcast("addItem", key)
}
} else {
s = new this.inner().set(value)
this.map.set(key, s)
this.broadcast("addItem", key)
}
return s
}
delete(key) {
if (this.backing.has(key)) {
this.backing.delete(key)
this.broadcast("deleteItem", key)
}
}
}
module.exports = {SubscribeMap}

View file

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

View file

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

View file

@ -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,29 +37,50 @@ function sync() {
function manageSync(root) {
try {
let newEvents = false
let notificationsChange = false
// set up directs
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
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.updateStateEvents(data.state.events)
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
}
})
}
}
// set up groups
if (root.groups) {
Promise.all(
Object.keys(root.groups.join).map(id => {
if (!store.groups.has(id)) {
@ -94,7 +115,10 @@ function manageSync(root) {
).then(() => {
store.rooms.sort()
})
}
if (newEvents) store.newEvents.broadcast("changeSelf")
if (notificationsChange) store.notificationsChange.broadcast("changeSelf")
} catch (e) {
console.error(root)
throw e

View file

@ -1,16 +1,20 @@
const {ElemJS, ejs} = require("./basic.js")
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")
const dateFormatter = Intl.DateTimeFormat("default", {hour: "numeric", minute: "numeric", day: "numeric", month: "short", year: "numeric"})
let sentIndex = 0
function getTxnId() {
@ -38,68 +42,6 @@ function eventSearch(list, event, min = 0, max = NO_MAX) {
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 if (this.data.content.membership === "invite") {
this.child(ejs("i").text(`invited ${this.data.content.displayname} to the room`))
} else if (this.data.content.membership === "leave") {
this.child(ejs("i").text("left the room"))
} else {
this.child(ejs("i").text("unknown membership event"))
}
} else if (this.data.type === "m.room.encrypted") {
this.child(ejs("i").text("Carbon does not yet support encrypted messages."))
} else {
this.child(ejs("i").text(`Unsupported event type ${this.data.type}`))
}
if (this.editedAt) {
this.child(ejs("span").class("c-message__edited").text("(edited)").attribute("title", "at " + dateFormatter.format(this.editedAt)))
}
}
}
class EventGroup extends ElemJS {
constructor(reactive, list) {
super("div")
@ -123,6 +65,11 @@ class EventGroup extends ElemJS {
)
}
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)
@ -141,7 +88,6 @@ class EventGroup extends ElemJS {
}
}
/** Displays a spinner and creates an event to notify timeline to load more messages */
class LoadMore extends ElemJS {
constructor(id) {
@ -198,25 +144,34 @@ class ReactiveTimeline extends ElemJS {
}
tryAddGroups(event, indices) {
const success = indices.some(i => {
if (!this.list[i]) {
const createGroupAt = i => {
// if (printed++ < 100) console.log("tryadd success, created group")
const group = new EventGroup(this, [event])
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 (this.list[i] && this.list[i].data.sender === event.data.sender) {
} 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)
// 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) {
@ -255,8 +210,12 @@ class Timeline extends Subscribable {
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
}
@ -282,23 +241,27 @@ class Timeline extends Subscribable {
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
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 target = this.map.get(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(eventData.content["chat.carbon.message.pending_id"])
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 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
@ -314,9 +277,11 @@ class Timeline extends Subscribable {
continue
}
// add new event
const event = new Event(eventData)
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
@ -335,6 +300,46 @@ class Timeline extends Subscribable {
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()
@ -347,7 +352,7 @@ class Timeline extends Subscribable {
async loadScrollback() {
debug = true
if (!this.from) throw new Error("Can't load scrollback, no from token")
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)
@ -368,24 +373,21 @@ class Timeline extends Subscribable {
if (root.chunk.length) {
// there are events to display
this.updateEvents(root.chunk)
} else {
}
if (!root.chunk.length || !root.end) {
// we reached the top of the scrollback
this.reactiveTimeline.loadMore.remove()
}
this.broadcast("afterScrollbackLoad")
}
send(body) {
send(type, content) {
const tx = getTxnId()
const id = `pending$${tx}`
this.pending.add(id)
const content = {
msgtype: "m.text",
body,
"chat.carbon.message.pending_id": id
}
content["chat.carbon.message.pending_id"] = id
const fakeEvent = {
type: "m.room.message",
type,
origin_server_ts: Date.now(),
event_id: id,
sender: lsm.get("mx_user_id"),

69
src/js/typing.js Normal file
View file

@ -0,0 +1,69 @@
const {ElemJS, ejs, q} = require("./basic")
const {store} = require("./store/store")
const lsm = require("./lsm")
/**
* Maximum number of typing users to display all names for.
* More will be shown as "X users are typing".
*/
const maxUsers = 4
function getMemberName(mxid) {
return store.activeRoom.value().getMemberName(mxid)
}
class Typing extends ElemJS {
constructor() {
super(q("#c-typing"))
this.typingUnsubscribe = null
this.message = ejs("span")
this.child(this.message)
store.activeRoom.subscribe("changeSelf", this.changeRoom.bind(this))
}
changeRoom() {
if (this.typingUnsubscribe) {
this.typingUnsubscribe()
this.typingUnsubscribe = null
}
if (!store.activeRoom.exists()) return
const room = store.activeRoom.value()
this.typingUnsubscribe = room.timeline.typing.subscribe("changeSelf", this.render.bind(this))
this.render()
}
render() {
if (!store.activeRoom.exists()) return
const room = store.activeRoom.value()
let users = [...room.timeline.typing.value()]
// don't show own typing status
users = users.filter(u => u !== lsm.get("mx_user_id"))
if (users.length === 0) {
// nobody is typing
this.removeClass("c-typing--typing")
} else {
let message = ""
if (users.length === 1) {
message = `${getMemberName(users[0])} is typing...`
} else if (users.length <= maxUsers) {
// feel free to rewrite this loop if you know a better way
for (let i = 0; i < users.length; i++) {
if (i < users.length-1) {
message += `${getMemberName(users[i])}, `
} else {
message += `and ${getMemberName(users[i])} are typing...`
}
}
} else {
message = `${users.length} people are typing...`
}
this.class("c-typing--typing")
this.message.text(message)
}
}
}
new Typing()

View file

@ -15,28 +15,18 @@ html
.data-input
.form-input-container
label(for="username") Username
input(type="text" name="username" autocomplete="username" placeholder="@username:server.com" pattern="^@?[a-z0-9._=/-]+(?::[a-zA-Z0-9.:\\[\\]-]+)?$" required)#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
.homeserver-label
label(for="homeserver") Homeserver
span.homeserver-question(tabindex=0) (What's this?)
input(type="text" name="homeserver" placeholder="matrix.org")#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
template#homeserver-popup-template
.homeserver-popup
p
| Homeserver is the place where your account lives.
| It's usually the website where you registered.
p
| Need help finding a homeserver?
a(href='#') Click here

View file

@ -21,3 +21,39 @@ body
.main
height: 100vh
display: flex
button
appearance: none
border: none
background: none
color: inherit
font-family: inherit
font-size: inherit
font-style: inherit
font-weight: inherit
padding: 0
margin: 0
line-height: inherit
cursor: inherit
// focus resets
:focus
outline: none
:-moz-focusring
outline: none
::-moz-focus-inner
border: 0
select:-moz-focusring
color: transparent
text-shadow: 0 0 0 #ddd
body.show-focus
a, select, button, input, video, div, span
outline-color: #fff
&:focus
outline: 2px dotted

View file

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

View file

@ -0,0 +1,50 @@
@use "../colors" as c
.c-chat-banner
position: sticky
z-index: 1
top: 0
left: 0
right: 0
margin-right: 12px
outline-color: #000
opacity: 0
transform: translateY(-40px)
transition: transform 0.2s ease, opacity 0.2s ease-out
&--active
opacity: 1
transform: translateY(0px)
&__inner
display: grid
grid-template-columns: 1fr auto
background: c.$notify-highlight
color: #000
margin: 0px 12px
padding: 0px 12px
border-radius: 0px 0px 10px 10px
line-height: 1
box-shadow: 0px 5px 5px -2px rgba(0, 0, 0, 0.1)
cursor: pointer
&:hover
box-shadow: 0px 5px 5px -2px rgba(0, 0, 0, 0.6)
&__part
padding: 6px 0px 8px
&:hover
text-decoration: underline
&__part-inner
display: block
width: 100% // yes, really.
text-align: left
&__last
margin-left: 8px
&__last &__part-inner
border-left: 1px solid #222
padding-left: 8px

View file

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

View file

@ -2,11 +2,12 @@
.c-chat
display: grid
grid-template-rows: 1fr 82px // fixed so that input box height adjustment doesn't mess up scroll
grid-template-rows: 0 1fr 82px // fixed so that input box height adjustment doesn't mess up scroll
align-items: end
flex: 1
&__messages
position: relative
height: 100%
overflow-y: scroll
scrollbar-color: c.$darkest c.$darker

View file

@ -36,11 +36,13 @@ $out-width: $base-width + rooms.$list-width
box-sizing: border-box
.c-group
position: relative
display: flex
align-items: center
padding: $icon-padding / 2 $icon-padding
cursor: pointer
border-radius: 8px
background-color: c.$darkest
&:hover
background-color: c.$darker
@ -62,6 +64,29 @@ $out-width: $base-width + rooms.$list-width
overflow: hidden
text-overflow: ellipsis
&__number
position: absolute
right: 240px
bottom: 0px
background: #ddd
color: #000
font-size: 14px
line-height: 1
padding: 3px 4px
border-radius: 7px
border: 3px solid c.$darkest
opacity: 0
transform: translate(6px, 6px)
transition: transform 0.15s ease-out, opacity 0.15s ease-out
pointer-events: none
@at-root .c-group:hover &
border-color: c.$darker
&--active
opacity: 1
transform: translate(0px, 0px)
.c-group-marker
position: absolute
top: 5px

View file

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

View file

@ -1,6 +1,6 @@
@use "../colors" as c
.c-event-groups *
.c-event-groups > *
overflow-anchor: none
.c-message-group, .c-message-event
@ -9,7 +9,8 @@
border-top: 1px solid c.$divider
.c-message-group
display: flex
display: grid
grid-template-columns: auto 1fr
&__avatar
flex-shrink: 0
@ -50,6 +51,15 @@
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
@ -67,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
@ -88,3 +150,37 @@
padding: 12px
background-color: c.$milder
border-radius: 8px
.c-media
&__wrapper
overflow: hidden
position: relative
&--spoiler
cursor: pointer
img
filter: blur(40px)
&--shown img
filter: none
&__spoiler
position: absolute
top: 0
bottom: 0
left: 0
right: 0
display: flex
align-items: center
justify-content: center
font-size: 18px
font-weight: 500
color: #fff
text-transform: uppercase
background: rgba(0, 0, 0, 0.3)
cursor: pointer
pointer-events: none
&--shown &__spoiler
display: none

View file

@ -0,0 +1,42 @@
@use "../colors" as c
.c-read-marker
display: none
position: relative
&--attached
display: block
&__inner
position: absolute
left: -64px
right: 0px
height: 2px
top: 0px
background-color: c.$notify-highlight
@at-root .c-message:last-child &
top: 11px
@at-root .c-message-event &
top: 7px
&__text
position: absolute
right: -14px
top: -9px
display: flex
align-items: center
background-color: c.$notify-highlight
color: #000
font-size: 12px
font-weight: 600
line-height: 1
padding: 4px
border-radius: 5px
text-transform: uppercase
&__loading
background-color: #000
width: 10px
height: 10px

View file

@ -43,3 +43,23 @@ $icon-padding: 8px
white-space: nowrap
overflow: hidden
text-overflow: ellipsis
flex: 1
&__number
flex-shrink: 0
line-height: 1
padding: 4px 5px
border-radius: 5px
font-size: 14px
pointer-events: none
&--none
display: none
&--unreads
background-color: #ddd
color: #111
&--notifications
background-color: #ffac4b
color: #000

View file

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

View file

@ -0,0 +1,21 @@
@use "../colors" as c
.c-typing
height: 39px
background: c.$divider
position: absolute
right: 0
left: 0
top: 0
z-index: 0
margin: 20px
border-radius: 8px
padding: 0px 12px
font-size: 14px
line-height: 19px
transform: translateY(0px)
transition: transform 0.15s ease
color: #fff
&--typing
transform: translateY(-21px)

View file

@ -1,7 +1,6 @@
@use "./base"
@use "./loading"
@use "./colors" as c
@use "./tippy"
@use "./loading.sass"
@use "./colors.sass" as c
.main
@ -44,39 +43,6 @@
.form-error
color: red
.homeserver-question
font-size: 0.8em
.homeserver-label
display: flex
justify-content: space-between
align-items: flex-end
.homeserver-popup
p
margin: 0.2em
a
&, &:hover, &:active
color: white
text-decoration: none
&:hover
color: #f00 //Placeholder
@keyframes spin
0%
transform: rotate(0deg)
100%
transform: rotate(180deg)
.loading-icon
display: inline-block
background-color: #ccc
width: 12px
height: 12px
margin-right: 6px
animation: spin 0.7s infinite
input, button
font-family: inherit
@ -101,7 +67,6 @@ button, input[type="submit"]
padding: 7px
&:hover
cursor: pointer
background-color: c.$milder
label

View file

@ -1,8 +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 "./loading"
@use "./components/highlighted-code"
@use "./components/read-marker"
@use "./components/chat-banner"
@use "./components/spoilers"

View file

@ -1,10 +0,0 @@
@use "../../node_modules/tippy.js/dist/tippy.css"
@use "../../node_modules/tippy.js/dist/svg-arrow.css"
@use "./colors.sass" as c
.tippy-box[data-theme~="carbon"]
background-color: c.$milder
border: 2px solid c.$divider
.tippy-svg-arrow
fill: c.$milder