Compare commits

...

147 Commits

Author SHA1 Message Date
38968c3247 Get Scylla building with nix using elm2nix
https://github.com/cachix/elm2nix

Signed-off-by: Danila Fedorin <danila.fedorin@gmail.com>
2025-12-27 19:02:13 +00:00
71845ae091 Fix sender names not being used 2019-10-09 12:52:59 -07:00
4505b4ba27 Stop making dozens of /profile calls to get usernames 2019-10-09 12:42:33 -07:00
4ef8471585 Implement part of a push-based notification system 2019-10-04 21:26:11 -07:00
c3c2036c69 Fix incorrectly decoding state events in timeline 2019-09-14 21:01:51 -07:00
c3ed5c4cd1 Ignore nextBatch from timeline 2019-09-12 20:32:59 -07:00
105f7e6012 Add missing changes from previous commit 2019-09-12 16:00:23 -07:00
c594d9858f Rename some functions to be more clear 2019-09-12 15:56:21 -07:00
71e0b3f64e Perform further cleanup to Sync 2019-09-11 01:11:08 -07:00
8627123143 Remove all the merging code from Sync 2019-09-11 01:07:39 -07:00
5c02ae8a58 Fully switch away from keeping sync 2019-09-11 00:52:42 -07:00
29e81a88ac Start switching from sync to room data 2019-09-10 23:24:47 -07:00
676d6c28a7 Add initial implementation of new room structure 2019-09-10 22:33:58 -07:00
595e28853e Split Sync file into sub-modules 2019-09-10 20:19:26 -07:00
ccfd2fe76b Fix botched unique implementation 2019-09-09 01:56:38 -07:00
911e46c4c3 Add support for m.notice and m.emote 2019-09-08 15:00:52 -07:00
266c421223 Fix flickering (thanks Matrix spec) 2019-09-08 14:22:08 -07:00
3b1dabd624 Remove homebrew notification system. Will be using the spec for this. 2019-09-07 18:28:58 -07:00
1d50c5b1e4 Start working towards setting notification settings 2019-09-07 18:03:38 -07:00
360b7be281 Change room name func to match user name func 2019-09-07 17:06:23 -07:00
06799194e4 Move account data code into account data module 2019-09-07 16:55:56 -07:00
8623eb8dfd Refactor new user command 2019-09-07 16:38:09 -07:00
db2def5388 Update screenshot 2019-09-07 00:11:13 -07:00
5e3aa40a35 Use Elm's lazy to optimize for many-message performance 2019-09-06 23:55:36 -07:00
7122d9e567 Put uniqueBy back in sync 2019-09-02 01:50:53 -07:00
207f6ab3be Remove dependency on model in message list 2019-09-02 01:10:28 -07:00
f395259137 Improve performance by computing room names at sync, rather than on view. 2019-09-02 00:46:59 -07:00
5d5418e9c6 Use m.direct for direct message names. 2019-09-01 00:37:30 -07:00
b23c80f463 Switch to a tail recursive version of uniqueBy 2019-08-31 23:00:52 -07:00
f6ce669fb4 Fix font size property 2019-08-23 21:22:38 -07:00
392d799bcf Fix missing padding in reconnect message 2019-08-21 23:29:47 -07:00
115cbd9a76 Tone the font size down 2019-08-21 21:46:00 -07:00
bc794955e3 Prototype switching to em. 2019-08-21 21:40:58 -07:00
b8fc33eae6 Make minor visual changes 2019-08-21 21:20:30 -07:00
7d09b4ad9a Make notifications more consistent. 2019-08-21 18:40:45 -07:00
61121ee6f8 Stop decoding event contents; they seem to vary event-to-event 2019-08-21 18:21:46 -07:00
60bc00e9dd Fix overflow in chat list. 2019-05-19 15:51:33 -07:00
8120c1f421 Update screenshot. 2019-05-19 15:38:46 -07:00
86b1b29d72 Decrease notification icon size a bit. 2019-05-19 15:33:55 -07:00
150af81847 Change alert indicator to work better. 2019-05-19 15:01:02 -07:00
151ff413c7 Fix up room list's width. 2019-05-19 13:47:06 -07:00
3a31f98f3b Wire up the search bar. 2019-05-19 13:42:22 -07:00
a4c40dca28 Add an (un-wired) input for searching rooms. 2019-05-19 13:32:57 -07:00
8173a4d74a Add some transition effects. 2019-05-19 13:26:30 -07:00
7be4e8d9e2 Make small tweaks to room list. 2019-05-19 13:23:16 -07:00
6e5702290a Get rid of some inconsistent shadows and borders. 2019-05-19 01:34:14 -07:00
8560f15047 Make some view adjustments. 2019-05-19 01:32:39 -07:00
47a684b777 Alphabetically sort rooms. 2019-05-15 20:48:31 -07:00
5bd6124df2 Move the room name code into a separate function. 2019-05-15 20:40:21 -07:00
63fcb22998 Display user names in private chats. 2019-05-15 20:27:06 -07:00
0dda1068fb Add shadow to images.
This helps them stand out from the other messages. In the future,
we'll also allow clicking on them to enlarge.
2019-03-17 12:44:52 -07:00
66b602a603 Add more ignored files via gitignore.io 2019-03-17 12:38:35 -07:00
6f2add1f43 Merge pull request #6 from aaronraimist/gitignore
Add basic .gitignore
2019-03-17 12:35:00 -07:00
4961a955e1 Merge pull request #7 from aaronraimist/url-placeholder
Add placeholder for homeserver URL
2019-03-17 12:34:26 -07:00
Aaron Raimist
7aa4e11e31 Add basic .gitignore 2019-03-17 12:14:01 -05:00
Aaron Raimist
df0be6a1ee Add placeholder for homeserver URL
I forgot this one
2019-03-17 12:03:18 -05:00
5ab574ea00 Merge pull request #5 from aaronraimist/patch-1
Add clickable badge for the room
2019-03-16 19:51:02 -07:00
Aaron Raimist
97087ccd4c Add clickable badge for the room 2019-03-16 20:49:10 -05:00
07efa8764a Add new room address to the README 2019-03-16 18:18:41 -07:00
c2ab3b719f Merge pull request #4 from aaronraimist/login-form-placeholders
Add placeholders to the username and password field on login form
2019-03-16 18:11:57 -07:00
Aaron Raimist
b8939dacbb Add placeholders to the username and password field on login form 2019-03-16 19:50:21 -05:00
66c84cabbf Add newlines to build instructions.
Oh, markdown...
2019-03-16 17:33:26 -07:00
77edc8464f Add building instructions to the README. 2019-03-16 17:27:16 -07:00
f5f24f389f Merge branch 'master' of https://github.com/DanilaFe/Scylla 2019-03-15 18:59:09 -07:00
c7149aa5c9 Allow more images. 2019-03-15 18:56:17 -07:00
6e721d685b Grey out messages that are still sending. 2019-03-15 18:50:09 -07:00
011630a185 Remove messages once their ID is received. 2019-03-15 18:45:55 -07:00
f2a8acc59c Decode id strings. 2019-03-15 18:01:26 -07:00
1b0ad433b9 Add id field for sending messages.
The idea is to use this field to dismiss messages only when
a sync response with their id arrives.
2019-03-15 18:01:07 -07:00
7241d112b0 Add notification counts to page title. 2019-03-15 17:44:54 -07:00
f3af964a99 Create LICENSE
Thanks to #1 :)
2019-03-06 12:33:50 -08:00
3471f6cb74 Update screenshots 2019-02-25 21:15:13 -08:00
764a37317b Change colors in input. 2019-02-25 20:40:46 -08:00
aa4196ee69 Make the buttons round. 2019-02-25 20:34:02 -08:00
fe065130fe Scroll after "sending" messages. 2019-02-25 20:16:06 -08:00
267d68d673 fixup! Display "still sending" messages. 2019-02-25 20:04:15 -08:00
5f8751e142 Update CSS. 2019-02-25 19:58:05 -08:00
5d519242be Display "still sending" messages. 2019-02-25 19:54:54 -08:00
2136bf34b9 Create an abstraction for room data.
Unless you specifically need the Sync data, this will be more useful,
since it stores the messages being sent and the like.
2019-02-25 18:09:39 -08:00
6c67e85ca5 Add shadows to room css. 2019-02-25 17:39:25 -08:00
be7ea33085 Remove the "every other row" darkening. 2019-02-25 17:26:52 -08:00
ce1580926c Refactor to allow "messages".
This will allow us to group non-event things as messages, which will
then allow us to display messages that are still being sent.
2019-02-25 16:44:47 -08:00
1703c091a7 Send file names as captions when sending files and images. 2019-02-21 23:03:01 -08:00
28c829c2c8 Attempt to fix image "double sending" issue. 2019-01-24 21:14:02 -08:00
cf05f9dc4a Filter useless state events on the server side.
This means that presence events appear in the timeline, which is
something we DO want.
2018-12-27 22:33:48 -08:00
b0e796ee16 Add a button to reconnect. 2018-12-27 00:12:48 -08:00
4de9063e67 Disable DDOS-tier retry attempts on failed network communication. 2018-12-26 23:20:51 -08:00
85e410fc20 Fix table cell max size. 2018-12-26 21:54:44 -08:00
473101a15e Switch to dark CSS. 2018-12-25 18:28:32 -08:00
d0c21cc2fa Filter some useless events to improve performance. 2018-12-25 18:01:16 -08:00
3c91be9fb6 Group rooms by homeserver. 2018-12-24 14:17:57 -08:00
1d3b0febde Refactor some code. 2018-12-23 21:29:48 -08:00
70d6eba427 Add basic notification priority filtering. 2018-12-23 21:17:03 -08:00
e762864b45 Add screenshots. 2018-12-23 20:41:55 -08:00
6ea55241c8 Add screenshots. 2018-12-23 20:38:04 -08:00
12e5fdfbf1 Add video and file support. 2018-12-23 20:26:35 -08:00
525a6dd878 Add the basic HTML file. 2018-12-23 19:20:09 -08:00
859023942e Add a README. 2018-12-23 19:19:58 -08:00
2d133167ed Add error messages to some scenarios. 2018-12-23 00:39:20 -08:00
c08ef14832 Add display to errors. 2018-12-23 00:23:48 -08:00
50701e1885 Add retrieving account data, namely the notification setting. 2018-12-22 21:42:51 -08:00
490d0eff2c Fix typing menu to stay the same size. 2018-12-22 00:44:05 -08:00
d9ede51428 Use a text area for multiline input. 2018-12-22 00:05:32 -08:00
ee21fa199d Add CSS for code blocks and quotes. 2018-12-21 19:31:42 -08:00
d1a4035fef Fix duplicate function. 2018-12-21 18:52:17 -08:00
590764adc4 Add markdown rendering for messages that have it. 2018-12-20 22:59:31 -08:00
eb9e82483b Add markdown sending. 2018-12-20 22:01:09 -08:00
c483e6ac6c Add mime type info to file and image uploads. 2018-12-20 20:13:09 -08:00
03c472a78d Remove content-type from basic headers. 2018-12-20 19:45:58 -08:00
356c10cf24 Add sending images and files 2018-12-20 19:45:41 -08:00
98be6ed061 Add file buttons and messages (currently do nothing) 2018-12-20 17:03:26 -08:00
2cdfc45a93 Update new users in history responses. 2018-12-20 16:39:10 -08:00
437039bcc4 Properly collect state events from a room. 2018-12-19 23:41:55 -08:00
130b964d29 Add "load older messages" button 2018-12-19 21:52:07 -08:00
c88f2f3b3c Remove unused flags. 2018-12-17 22:07:27 -08:00
47e8969290 Store the transaction ID locally, too. 2018-12-17 21:56:24 -08:00
7de91104b0 Send typing notifications. 2018-12-17 19:56:50 -08:00
7f0624f112 Add http for sending typing indicators. 2018-12-17 19:41:43 -08:00
6d39279591 Allow for non-string values in responses, and ports. 2018-12-17 16:32:39 -08:00
cf2ada4329 Add code to retrieve login token after login. 2018-12-17 16:18:47 -08:00
128430b38f Add encoding and decoding of login datas a function. 2018-12-17 15:34:21 -08:00
471f5b301b Store the ApiUrl in the message, so that user can't change it midway. 2018-12-17 02:40:39 -08:00
6f1e3da27b Add storing login token (but not retrieving it) 2018-12-17 02:34:46 -08:00
720e6db334 Read room state events from timeline, too. 2018-12-15 23:36:05 -08:00
983592d520 Set read marker on opening room. 2018-12-15 20:56:17 -08:00
6c96bae01f Send read receipts for messages received after opening room. 2018-12-15 19:22:49 -08:00
2529f6f7ae Add padding to typing message. 2018-12-15 18:14:40 -08:00
55f40d5a51 Cleanup command code. 2018-12-14 00:08:16 -08:00
78620c3b4f Scroll when new messages arrive and user is close to the bottom. 2018-12-14 00:02:15 -08:00
fdb3213ec5 Decrease the font size. 2018-12-13 21:58:15 -08:00
2e804f84a3 Render image messages. 2018-12-13 19:45:55 -08:00
92a7820a8e Add sending messages on enter. 2018-12-13 18:41:54 -08:00
00b6462fe4 Fix input box shrinking in Chrome. 2018-12-13 18:41:43 -08:00
2c99d10075 Change table layout.
Dynamically resizing is a pain in the ass and not portable.
2018-12-13 18:06:20 -08:00
d95e383fb1 Add room notifications. 2018-12-13 17:47:58 -08:00
56878533f4 Add "typing people wrapper" 2018-12-13 16:28:13 -08:00
5627168d20 Add new lookup functions. 2018-12-13 16:01:54 -08:00
0e2cd8c9e9 Store ephemeral events 2018-12-13 14:51:09 -08:00
2c7b72fba6 Open room on notification click. 2018-12-13 14:06:15 -08:00
46352c429a Add notifications 2018-12-13 13:42:23 -08:00
b25e5d77af Retrieve user display names on initial log in. 2018-12-13 12:45:30 -08:00
996da079e2 Begin working on requesting users' names. 2018-12-13 02:38:25 -08:00
2449a3c8c4 Add requesting user data. 2018-12-13 02:37:59 -08:00
2505610aa2 Add a separate message for first sync, and add event listing. 2018-12-13 01:46:57 -08:00
3e7d12b6e4 Add notification sending functionality. 2018-12-12 00:03:17 -08:00
00ba3a6ac0 Max username overflow hidden. 2018-12-10 23:27:45 -08:00
8338eef381 Implement improvements suggested by Ryan. 2018-12-10 23:24:17 -08:00
39 changed files with 2322 additions and 522 deletions

17
.gitignore vendored Normal file
View File

@@ -0,0 +1,17 @@
/static/js/elm.js
/static/css/style.css
# Created by https://www.gitignore.io/api/elm,sass
# Edit at https://www.gitignore.io/?templates=elm,sass
### Elm ###
# elm-package generated files
elm-stuff
# elm-repl generated files
repl-temp-*
### Sass ###
.sass-cache/
*.css.map
*.sass.map
*.scss.map

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019 Danila Fedorin
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

53
README.md Normal file
View File

@@ -0,0 +1,53 @@
# Scylla
A minimalist client for the Matrix chat protocol. Come chat with me: [![#scylla:riot.danilafe.com](https://img.shields.io/matrix/scylla:riot.danilafe.com.svg?label=%23scylla:riot.danilafe.com&server_fqdn=matrix.org)](https://matrix.to/#/#scylla:riot.danilafe.com)
## Screenshots
![Main View](screenshots/screenshot-1.png)
![Login View](screenshots/screenshot-2.png)
![Error](screenshots/screenshot-3.png)
## Why?
Riot, the flagship Matrix client, is slow. Switching rooms has a noticable delay (probably around several hundred milliseconds).
History loading causes strange jumps in the scroll position. Scylla aims to be faster and more responsive,
while still maintaining all the necessary features for a chat client.
## What can it do?
Scylla currently supports the following features:
* Sending and receiving messages
* Sending and receiving images and files
* Converting Markdown into HTML for specially formatted messages
* Loading old history in a room
## Should I use Scylla?
Maybe. Scylla aims for a more minimalistic experience, remeniscent of IRC. It doesn't strive to have the rich chat client features
that Riot goes for, and attempts to polish the common tasks. If you would like a more advanced chat client, stick with Riot. However,
if you prefer polished minimalism, Scylla might be the client for you.
## Building Instructions
If you'd like to build Scylla, you need to take the following steps:
1. Install [Elm](https://elm-lang.org/) and [Sass](https://sass-lang.com/)
2. Compile Scylla's Elm and Sass source files:
```
elm make src/Main.elm --output static/js/elm.js --optimize
sass static/scss/style.scss static/css/style.css
```
3. Open the provided `index.html` file in your browser. You can't do it by just loading the file in Chrome;
you need to use an actual web server. I use Python2's built in HTTP server as such:
```
python2 -m SimpleHTTPServer
```
If you use this, visit the address `localhost:8000` once the server starts.
4. If you'd like to host Scylla, you need to be aware that it uses URLs as paths.
Because of this, in order for refreshing the page to work as intended,
you need to make sure that all URLs that don't start with "static" serve the
same `index.html` file. I use the following (Apache) configuration:
```
RewriteEngine on
RewriteCond %{REQUEST_URI} !^/static/
RewriteRule .* "/path/to/Scylla/index.html"
```

77
elm-dependencies.nix Normal file
View File

@@ -0,0 +1,77 @@
{
"NoRedInk/elm-json-decode-pipeline" = {
sha256 = "0y25xn0yx1q2xlg1yx1i0hg4xq1yxx6yfa99g272z8162si75hnl";
version = "1.0.0";
};
"elm/browser" = {
sha256 = "1zlmx672glg7fdgkvh5jm47y85pv7pdfr5mkhg6x7ar6k000vyka";
version = "1.0.1";
};
"elm/core" = {
sha256 = "1l0qdbczw91kzz8sx5d5zwz9x662bspy7p21dsr3f2rigxiix2as";
version = "1.0.2";
};
"elm/file" = {
sha256 = "15vw1ilbg0msimq2k9magwigp8lwqrgvz3vk6qia6b3ldahvw8jr";
version = "1.0.1";
};
"elm/html" = {
sha256 = "1n3gpzmpqqdsldys4ipgyl1zacn0kbpc3g4v3hdpiyfjlgh8bf3k";
version = "1.0.0";
};
"elm/http" = {
sha256 = "008bs76mnp48b4dw8qwjj4fyvzbxvlrl4xpa2qh1gg2kfwyw56v1";
version = "2.0.0";
};
"elm/json" = {
sha256 = "1a107nmm905dih4w4mjjkkpdcjbgaf5qjvr7fl30kkpkckfjjnrw";
version = "1.1.2";
};
"elm/svg" = {
sha256 = "1cwcj73p61q45wqwgqvrvz3aypjyy3fw732xyxdyj6s256hwkn0k";
version = "1.0.1";
};
"elm/time" = {
sha256 = "0vch7i86vn0x8b850w1p69vplll1bnbkp8s383z7pinyg94cm2z1";
version = "1.0.0";
};
"elm/url" = {
sha256 = "0av8x5syid40sgpl5vd7pry2rq0q4pga28b4yykn9gd9v12rs3l4";
version = "1.0.0";
};
"hecrj/html-parser" = {
sha256 = "0pla6hswsl9piwrj3yl4pc4nfs5adc4g4c93644j4xila7bqqg8a";
version = "2.0.0";
};
"elm/bytes" = {
sha256 = "040d7irrawcbnq9jxhzx8p9qacdlw5bncy6lgndd6inm53rvvwbp";
version = "1.0.7";
};
"elm/parser" = {
sha256 = "0a3cxrvbm7mwg9ykynhp7vjid58zsw03r63qxipxp3z09qks7512";
version = "1.1.0";
};
"elm/virtual-dom" = {
sha256 = "0q1v5gi4g336bzz1lgwpn5b1639lrn63d8y6k6pimcyismp2i1yg";
version = "1.0.2";
};
"rtfeldman/elm-hex" = {
sha256 = "1y0aa16asvwdqmgbskh5iba6psp43lkcjjw9mgzj3gsrg33lp00d";
version = "1.0.0";
};
}

View File

@@ -3,23 +3,26 @@
"source-directories": [
"src"
],
"elm-version": "0.19.0",
"elm-version": "0.19.1",
"dependencies": {
"direct": {
"NoRedInk/elm-json-decode-pipeline": "1.0.0",
"elm/browser": "1.0.1",
"elm/core": "1.0.2",
"elm/file": "1.0.1",
"elm/html": "1.0.0",
"elm/http": "2.0.0",
"elm/json": "1.1.2",
"elm/svg": "1.0.1",
"elm/url": "1.0.0"
"elm/time": "1.0.0",
"elm/url": "1.0.0",
"hecrj/html-parser": "2.0.0"
},
"indirect": {
"elm/bytes": "1.0.7",
"elm/file": "1.0.1",
"elm/time": "1.0.0",
"elm/virtual-dom": "1.0.2"
"elm/parser": "1.1.0",
"elm/virtual-dom": "1.0.2",
"rtfeldman/elm-hex": "1.0.0"
}
},
"test-dependencies": {

56
elm.nix Normal file
View File

@@ -0,0 +1,56 @@
{ lib, stdenv, elm, fetchElmDeps, uglify-js, sass }:
let
mkDerivation =
{ srcs ? ./elm-dependencies.nix
, src
, name
, srcdir ? "./src"
, targets ? []
, registryDat ? ./registry.dat
, outputJavaScript ? false
}:
stdenv.mkDerivation {
inherit name src;
buildInputs = [ elm sass ]
++ lib.optional outputJavaScript uglify-js;
buildPhase = fetchElmDeps {
elmPackages = import srcs;
elmVersion = "0.19.1";
inherit registryDat;
};
installPhase = let
elmfile = module: "${srcdir}/${builtins.replaceStrings ["."] ["/"] module}.elm";
extension = if outputJavaScript then "js" else "html";
in ''
${lib.concatStrings (map (module: ''
echo "compiling ${elmfile module}"
elm make ${elmfile module} --optimize --output $out/${module}.${extension}
${lib.optionalString outputJavaScript ''
echo "minifying ${elmfile module}"
uglifyjs $out/${module}.${extension} --compress 'pure_funcs="F2,F3,F4,F5,F6,F7,F8,F9,A2,A3,A4,A5,A6,A7,A8,A9",pure_getters,keep_fargs=false,unsafe_comps,unsafe' \
| uglifyjs --mangle --output $out/${module}.min.${extension}
''}
'') targets)}
# Custom logic for Scylla in particular
mkdir $out/static $out/static/js $out/static/css $out/static/svg
cp $src/index.html $out/index.html
cp $out/Main.min.js $out/static/js/elm.js
cp $src/static/js/*.js $out/static/js
cp $src/static/svg/*.svg $out/static/svg
sass $src/static/scss/style.scss $out/static/css/style.css
'';
};
in mkDerivation {
name = "Scylla-0.1.0";
srcs = ./elm-dependencies.nix;
src = ./.;
targets = ["Main"];
srcdir = "./src";
outputJavaScript = true;
}

61
flake.lock generated Normal file
View File

@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1766736597,
"narHash": "sha256-BASnpCLodmgiVn0M1MU2Pqyoz0aHwar/0qLkp7CjvSQ=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "f560ccec6b1116b22e6ed15f4c510997d99d5852",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixos-25.11",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

23
flake.nix Normal file
View File

@@ -0,0 +1,23 @@
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = { self, nixpkgs, flake-utils }:
flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = import nixpkgs { inherit system; };
Scylla = import ./elm.nix {
inherit (pkgs) lib stdenv sass;
inherit (pkgs.elmPackages) fetchElmDeps elm;
inherit (pkgs.nodePackages) uglify-js;
};
in
{
packages = { inherit Scylla; };
defaultPackage = Scylla;
}
);
}

21
index.html Normal file
View File

@@ -0,0 +1,21 @@
<!DOCTYPE HTML>
<html>
<head>
<link rel="stylesheet" href="/static/css/style.css">
<script src="/static/js/elm.js"></script>
<script src="/static/js/notifications.js"></script>
<script src="/static/js/storage.js"></script>
<script src="/static/js/markdown.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/0.5.2/marked.min.js"></script>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta charset="UTF-8">
</head>
<body>
<script>
app = Elm.Main.init({ "flags" : { "token" : null } });
setupNotificationPorts(app);
setupStorage(app);
setupMarkdownPorts(app);
</script>
</body>
</html>

BIN
registry.dat Normal file

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 518 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -1,51 +1,73 @@
module Main exposing (..)
import Browser exposing (application, UrlRequest(..))
import Browser.Navigation as Nav
import Browser.Dom exposing (Viewport, setViewportOf)
import Scylla.Room exposing (OpenRooms, applySync)
import Scylla.Sync exposing (..)
import Scylla.Sync.Events exposing (toMessageEvent, getType, getSender, getUnsigned)
import Scylla.Sync.AccountData exposing (..)
import Scylla.Sync.Push exposing (..)
import Scylla.ListUtils exposing (..)
import Scylla.Messages exposing (..)
import Scylla.Login exposing (..)
import Scylla.Api exposing (..)
import Scylla.Model exposing (..)
import Scylla.Http exposing (..)
import Scylla.Views exposing (viewFull)
import Scylla.Route exposing (Route(..))
import Scylla.Route exposing (Route(..), RoomId)
import Scylla.Notification exposing (..)
import Scylla.Storage exposing (..)
import Scylla.Markdown exposing (..)
import Scylla.Room exposing (..)
import Url exposing (Url)
import Url.Parser exposing (parse)
import Url.Builder
import Json.Encode
import Json.Decode
import Time exposing (every)
import Html exposing (div, text)
import File exposing (File)
import File.Select as Select
import Http
import Dict
import Task
type alias Flags =
{ token : Maybe String
}
syncTimeout = 10000
typingTimeout = 2000
init : Flags -> Url -> Nav.Key -> (Model, Cmd Msg)
init flags url key =
init : () -> Url -> Nav.Key -> (Model, Cmd Msg)
init _ url key =
let
model =
{ key = key
, route = Maybe.withDefault Unknown <| parse Scylla.Route.route url
, token = flags.token
, token = Nothing
, loginUsername = ""
, loginPassword = ""
, apiUrl = "https://matrix.org"
, sync =
{ nextBatch = ""
, rooms = Nothing
, presence = Nothing
, accountData = Nothing
}
, nextBatch = ""
, accountData = { events = Just [] }
, errors = []
, roomText = Dict.empty
, sending = Dict.empty
, transactionId = 0
, connected = True
, searchText = ""
, rooms = emptyOpenRooms
}
cmd = case flags.token of
Just _ -> Cmd.none
Nothing -> Nav.pushUrl key <| Url.Builder.absolute [ "login" ] []
cmd = getStoreValuePort "scylla.loginInfo"
in
(model, cmd)
view : Model -> Browser.Document Msg
view m =
{ title = "Scylla"
let
notificationString = getTotalNotificationCountString m.rooms
titleString = case notificationString of
Nothing -> "Scylla"
Just s -> s ++ " Scylla"
in
{ title = titleString
, body = viewFull m
}
@@ -56,51 +78,290 @@ update msg model = case msg of
ChangeLoginPassword p -> ({ model | loginPassword = p }, Cmd.none)
AttemptLogin -> (model, Scylla.Http.login model.apiUrl model.loginUsername model.loginPassword) -- TODO
TryUrl urlRequest -> updateTryUrl model urlRequest
ChangeRoute r -> ({ model | route = r }, Cmd.none)
ReceiveLoginResponse r -> updateLoginResponse model r
ReceiveSyncResponse r -> updateSyncResponse model r
ChangeRoomText r t -> ({ model | roomText = Dict.insert r t model.roomText}, Cmd.none)
OpenRoom s -> (model, Nav.pushUrl model.key <| roomUrl s)
ChangeRoute r -> updateChangeRoute model r
ViewportAfterMessage v -> updateViewportAfterMessage model v
ViewportChangeComplete _ -> (model, Cmd.none)
ReceiveLoginResponse a r -> updateLoginResponse model a r
ReceiveFirstSyncResponse r -> updateSyncResponse model r False
ReceiveSyncResponse r -> updateSyncResponse model r True
ReceiveUserData s r -> (model, Cmd.none)
ChangeRoomText r t -> updateChangeRoomText model r t
SendRoomText r -> updateSendRoomText model r
SendRoomTextResponse r -> (model, Cmd.none)
SendRoomTextResponse t r -> updateSendRoomTextResponse model t r
ReceiveCompletedReadMarker r -> (model, Cmd.none)
ReceiveCompletedTypingIndicator r -> (model, Cmd.none)
ReceiveStoreData d -> updateStoreData model d
TypingTick _ -> updateTypingTick model
History r -> updateHistory model r
ReceiveHistoryResponse r hr -> updateHistoryResponse model r hr
SendImages rid -> (model, Select.files [ "image/jpeg", "image/png", "image/gif" ] <| ImagesSelected rid)
SendFiles rid -> (model, Select.files [ "application/*" ] <| FilesSelected rid)
ImagesSelected rid f fs -> updateUploadSelected model rid f fs (ImageUploadComplete rid)
FilesSelected rid f fs -> updateUploadSelected model rid f fs (FileUploadComplete rid)
ImageUploadComplete rid mime ur -> updateImageUploadComplete model rid mime ur
FileUploadComplete rid mime ur -> updateFileUploadComplete model rid mime ur
SendImageResponse _ -> (model, Cmd.none)
SendFileResponse _ -> (model, Cmd.none)
ReceiveMarkdown md -> updateMarkdown model md
DismissError i -> updateDismissError model i
AttemptReconnect -> ({ model | connected = True }, firstSync model.apiUrl (Maybe.withDefault "" model.token))
UpdateSearchText s -> ({ model | searchText = s }, Cmd.none)
updateSendRoomText : Model -> String -> (Model, Cmd Msg)
requestScrollCmd : Cmd Msg
requestScrollCmd = Task.attempt ViewportAfterMessage (Browser.Dom.getViewportOf "messages-wrapper")
updateSendRoomTextResponse : Model -> Int -> Result Http.Error String -> (Model, Cmd Msg)
updateSendRoomTextResponse m t r =
let
updateFunction newId msg = case msg of
Just (rid, { body, id }) -> Just (rid, { body = body, id = Just newId })
Nothing -> Nothing
in
case r of
Ok s -> ({ m | sending = Dict.update t (updateFunction s) m.sending }, Cmd.none)
Err e -> ({ m | sending = Dict.remove t m.sending }, Cmd.none)
updateDismissError : Model -> Int -> (Model, Cmd Msg)
updateDismissError m i = ({ m | errors = (List.take i m.errors) ++ (List.drop (i+1) m.errors)}, Cmd.none)
updateMarkdown : Model -> MarkdownResponse -> (Model, Cmd Msg)
updateMarkdown m { roomId, text, markdown } =
let
storeValueCmd = setStoreValuePort ("scylla.loginInfo", Json.Encode.string
<| encodeLoginInfo
<| LoginInfo (Maybe.withDefault "" m.token) m.apiUrl m.loginUsername (m.transactionId + 1))
sendMessageCmd = sendMarkdownMessage m.apiUrl (Maybe.withDefault "" m.token) (m.transactionId + 1) roomId text markdown
newModel =
{ m | transactionId = m.transactionId + 1
, sending = Dict.insert (m.transactionId + 1) (roomId, { body = TextMessage text, id = Nothing }) m.sending
}
in
(newModel, Cmd.batch [ storeValueCmd, sendMessageCmd, requestScrollCmd ])
updateFileUploadComplete : Model -> RoomId -> File -> (Result Http.Error String) -> (Model, Cmd Msg)
updateFileUploadComplete m rid mime ur =
let
command = case ur of
Ok u -> sendFileMessage m.apiUrl (Maybe.withDefault "" m.token) (m.transactionId + 1) rid mime u
_ -> Cmd.none
newErrors = case ur of
Err e -> [ "Error uploading file. Please check your internet connection and try again." ]
_ -> []
in
({ m | errors = newErrors ++ m.errors, transactionId = m.transactionId + 1}, command)
updateImageUploadComplete : Model -> RoomId -> File -> (Result Http.Error String) -> (Model, Cmd Msg)
updateImageUploadComplete m rid mime ur =
let
command = case ur of
Ok u -> sendImageMessage m.apiUrl (Maybe.withDefault "" m.token) (m.transactionId + 1) rid mime u
_ -> Cmd.none
newErrors = case ur of
Err e -> [ "Error uploading image. Please check your internet connection and try again." ]
_ -> []
in
({ m | transactionId = m.transactionId + 1}, command)
updateUploadSelected : Model -> RoomId -> File -> List File -> (File -> Result Http.Error String -> Msg) -> (Model, Cmd Msg)
updateUploadSelected m rid f fs msg =
let
uploadCmds = List.map (uploadMediaFile m.apiUrl (Maybe.withDefault "" m.token) msg) (f::fs)
in
(m, Cmd.batch uploadCmds)
updateHistoryResponse : Model -> RoomId -> Result Http.Error HistoryResponse -> (Model, Cmd Msg)
updateHistoryResponse m r hr =
case hr of
Ok h -> ({ m | rooms = applyHistoryResponse r h m.rooms }, Cmd.none)
Err _ -> ({ m | errors = "Unable to load older history from server"::m.errors }, Cmd.none)
updateHistory : Model -> RoomId -> (Model, Cmd Msg)
updateHistory m r =
let
prevBatch = Dict.get r m.rooms
|> Maybe.andThen (.prevHistoryBatch)
command = case prevBatch of
Just pv -> getHistory m.apiUrl (Maybe.withDefault "" m.token) r pv
Nothing -> Cmd.none
in
(m, command)
updateChangeRoomText : Model -> RoomId -> String -> (Model, Cmd Msg)
updateChangeRoomText m roomId text =
let
typingIndicator = case (text, Dict.get roomId m.roomText) of
("", _) -> Just False
(_, Just "") -> Just True
(_, Nothing) -> Just True
_ -> Nothing
command = case typingIndicator of
Just b -> sendTypingIndicator m.apiUrl (Maybe.withDefault "" m.token) roomId m.loginUsername b typingTimeout
_ -> Cmd.none
in
({ m | roomText = Dict.insert roomId text m.roomText}, command)
updateTypingTick : Model -> (Model, Cmd Msg)
updateTypingTick m =
let
command = case currentRoomId m of
Just rid -> sendTypingIndicator m.apiUrl (Maybe.withDefault "" m.token) rid m.loginUsername True typingTimeout
Nothing -> Cmd.none
in
(m, command)
updateStoreData : Model -> Json.Encode.Value -> (Model, Cmd Msg)
updateStoreData m d = case (Json.Decode.decodeValue storeDataDecoder d) of
Ok { key, value } -> case key of
"scylla.loginInfo" -> updateLoginInfo m value
_ -> (m, Cmd.none)
Err _ -> (m, Cmd.none)
updateLoginInfo : Model -> Json.Encode.Value -> (Model, Cmd Msg)
updateLoginInfo m s = case Json.Decode.decodeValue (Json.Decode.map decodeLoginInfo Json.Decode.string) s of
Ok (Just { token, apiUrl, username, transactionId }) ->
(
{ m | token = Just token
, apiUrl = apiUrl
, loginUsername = username
, transactionId = transactionId
}
, firstSync apiUrl token
)
_ -> (m, Nav.pushUrl m.key <| Url.Builder.absolute [ "login" ] [])
updateChangeRoute : Model -> Route -> (Model, Cmd Msg)
updateChangeRoute m r =
let
joinedRoom = case r of
Room rid -> Dict.get rid m.rooms
_ -> Nothing
lastMessage = Maybe.map .messages joinedRoom
|> Maybe.andThen (findLastEvent (((==) "m.room.message") << .type_))
readMarkerCmd = case (r, lastMessage) of
(Room rid, Just re) -> setReadMarkers m.apiUrl (Maybe.withDefault "" m.token) rid re.eventId <| Just re.eventId
_ -> Cmd.none
in
({ m | route = r }, readMarkerCmd)
updateViewportAfterMessage : Model -> Result Browser.Dom.Error Viewport -> (Model, Cmd Msg)
updateViewportAfterMessage m vr =
let
cmd vp = if vp.scene.height - (vp.viewport.y + vp.viewport.height ) < 100
then Task.attempt ViewportChangeComplete <| setViewportOf "messages-wrapper" vp.viewport.x vp.scene.height
else Cmd.none
in
(m, Result.withDefault Cmd.none <| Result.map cmd vr)
updateSendRoomText : Model -> RoomId -> (Model, Cmd Msg)
updateSendRoomText m r =
let
token = Maybe.withDefault "" m.token
message = Maybe.andThen (\s -> if s == "" then Nothing else Just s)
<| Dict.get r m.roomText
command = Maybe.withDefault Cmd.none
<| Maybe.map (sendTextMessage m.apiUrl token m.transactionId r) message
combinedCmd = case message of
Nothing -> Cmd.none
Just s -> Cmd.batch
[ requestMarkdownPort { roomId = r, text = s }
, sendTypingIndicator m.apiUrl token r m.loginUsername False typingTimeout
]
in
({ m | roomText = Dict.insert r "" m.roomText, transactionId = m.transactionId + 1 }, command)
({ m | roomText = Dict.insert r "" m.roomText }, combinedCmd)
updateTryUrl : Model -> Browser.UrlRequest -> (Model, Cmd Msg)
updateTryUrl m ur = case ur of
Internal u -> (m, Nav.pushUrl m.key (Url.toString u))
_ -> (m, Cmd.none)
updateLoginResponse : Model -> Result Http.Error LoginResponse -> (Model, Cmd Msg)
updateLoginResponse model r = case r of
Ok lr -> ( { model | token = Just lr.accessToken } , Cmd.batch
updateLoginResponse : Model -> ApiUrl -> Result Http.Error LoginResponse -> (Model, Cmd Msg)
updateLoginResponse model a r = case r of
Ok lr -> ( { model | token = Just lr.accessToken, loginUsername = lr.userId, apiUrl = a }, Cmd.batch
[ firstSync model.apiUrl lr.accessToken
, Nav.pushUrl model.key <| Url.Builder.absolute [] []
, setStoreValuePort ("scylla.loginInfo", Json.Encode.string
<| encodeLoginInfo
<| LoginInfo lr.accessToken model.apiUrl lr.userId model.transactionId)
] )
Err e -> (model, Cmd.none)
Err e -> ({ model | errors = "Failed to log in. Are your username and password correct?"::model.errors }, Cmd.none)
updateSyncResponse : Model -> Result Http.Error SyncResponse -> (Model, Cmd Msg)
updateSyncResponse model r =
updateSyncResponse : Model -> Result Http.Error SyncResponse -> Bool -> (Model, Cmd Msg)
updateSyncResponse model r notify =
let
token = Maybe.withDefault "" model.token
nextBatch = Result.withDefault model.sync.nextBatch
nextBatch = Result.withDefault model.nextBatch
<| Result.map .nextBatch r
cmd = sync nextBatch model.apiUrl token
syncCmd = sync model.apiUrl token nextBatch
notification sr =
getPushRuleset model.accountData
|> Maybe.map (\rs -> getNotificationEvents rs sr)
|> Maybe.withDefault []
|> findFirstBy
(\(s, e) -> e.originServerTs)
(\(s, e) -> e.sender /= model.loginUsername)
notificationCmd sr = if notify
then Maybe.withDefault Cmd.none
<| Maybe.map (\(s, e) -> sendNotificationPort
{ name = roomLocalDisplayName model s e.sender
, text = getText e
, room = s
}) <| notification sr
else Cmd.none
room = currentRoomId model
roomMessages sr = case room of
Just rid -> List.filter (((==) "m.room.message") << .type_)
<| Maybe.withDefault []
<| Maybe.map (List.filterMap (toMessageEvent))
<| Maybe.andThen .events
<| Maybe.andThen .timeline
<| Maybe.andThen (Dict.get rid)
<| Maybe.andThen .join
<| sr.rooms
Nothing -> []
setScrollCmd sr = if List.isEmpty
<| roomMessages sr
then Cmd.none
else requestScrollCmd
setReadReceiptCmd sr = case (room, List.head <| List.reverse <| roomMessages sr) of
(Just rid, Just re) -> setReadMarkers model.apiUrl token rid re.eventId <| Just re.eventId
_ -> Cmd.none
receivedEvents sr = List.map Just <| allTimelineEventIds sr
receivedTransactions sr = List.filterMap (Maybe.andThen .transactionId << getUnsigned)
<| allTimelineEvents sr
sending sr = Dict.filter (\tid (rid, { body, id }) -> not <| List.member (String.fromInt tid) <| receivedTransactions sr) model.sending
newModel sr =
{ model | nextBatch = nextBatch
, sending = sending sr
, rooms = applySync sr model.rooms
, accountData = applyAccountData sr.accountData model.accountData
}
in
case r of
Ok sr -> ({ model | sync = mergeSyncResponse model.sync sr }, cmd)
_ -> (model, cmd)
Ok sr -> (newModel sr
, Cmd.batch
[ syncCmd
, notificationCmd sr
, setScrollCmd sr
, setReadReceiptCmd sr
])
_ -> ({ model | connected = False }, Cmd.none)
subscriptions : Model -> Sub Msg
subscriptions m = Sub.none
subscriptions m =
let
currentText = Maybe.withDefault ""
<| Maybe.andThen (\rid -> Dict.get rid m.roomText)
<| currentRoomId m
typingTimer = case currentText of
"" -> Sub.none
_ -> every typingTimeout TypingTick
in
Sub.batch
[ onNotificationClickPort OpenRoom
, receiveStoreValuePort ReceiveStoreData
, typingTimer
, receiveMarkdownPort ReceiveMarkdown
]
onUrlRequest : Browser.UrlRequest -> Msg
onUrlRequest = TryUrl

View File

@@ -6,9 +6,7 @@ type alias ApiToken = String
type alias ApiUrl = String
basicHeaders : List Header
basicHeaders =
[ header "Content-Type" "application/json"
]
basicHeaders = []
authenticatedHeaders : ApiToken -> List Header
authenticatedHeaders token =

View File

@@ -1,59 +1,130 @@
module Scylla.Http exposing (..)
import Scylla.Model exposing (..)
import Scylla.Api exposing (..)
import Scylla.Sync exposing (syncResponseDecoder)
import Scylla.Route exposing (RoomId)
import Scylla.Sync exposing (syncResponseDecoder, historyResponseDecoder)
import Scylla.Login exposing (loginResponseDecoder, Username, Password)
import Json.Encode exposing (object, string, int)
import Http exposing (request, emptyBody, jsonBody, expectJson, expectWhatever)
import Scylla.UserData exposing (userDataDecoder, UserData)
import Url.Builder
import Json.Encode exposing (object, string, int, bool, list)
import Json.Decode as Decode exposing (field)
import Http exposing (request, emptyBody, jsonBody, fileBody, expectJson, expectWhatever)
import File exposing (File, name, mime)
import Url.Builder as Builder
import Json.Decode
fullUrl : ApiUrl -> ApiUrl
fullUrl s = s ++ "/_matrix/client/r0"
firstSyncFilter : Json.Decode.Value
firstSyncFilter = object
[ ("room", object
[ ("state", object
[ ("types", list string [ "m.room.name", "m.room.member" ])
])
])
]
firstSyncFilterString : String
firstSyncFilterString = Json.Encode.encode 0 firstSyncFilter
fullClientUrl : ApiUrl -> ApiUrl
fullClientUrl s = s ++ "/_matrix/client/r0"
fullMediaUrl : ApiUrl -> ApiUrl
fullMediaUrl s = s ++ "/_matrix/media/r0"
-- Http Requests
firstSync : ApiUrl -> ApiToken -> Cmd Msg
firstSync apiUrl token = request
{ method = "GET"
, headers = authenticatedHeaders token
, url = (fullUrl apiUrl) ++ "/sync"
, url = Url.Builder.crossOrigin (fullClientUrl apiUrl) [ "sync" ] [ Url.Builder.string "filter" firstSyncFilterString ]
, body = emptyBody
, expect = expectJson ReceiveSyncResponse syncResponseDecoder
, expect = expectJson ReceiveFirstSyncResponse syncResponseDecoder
, timeout = Nothing
, tracker = Nothing
}
sync : String -> ApiUrl -> ApiToken -> Cmd Msg
sync nextBatch apiUrl token = request
sync : ApiUrl -> ApiToken -> String -> Cmd Msg
sync apiUrl token nextBatch = request
{ method = "GET"
, headers = authenticatedHeaders token
, url = (fullUrl apiUrl) ++ "/sync" ++ "?since=" ++ (nextBatch) ++ "&timeout=10000"
, url = (fullClientUrl apiUrl) ++ "/sync" ++ "?since=" ++ (nextBatch) ++ "&timeout=10000"
, body = emptyBody
, expect = expectJson ReceiveSyncResponse syncResponseDecoder
, timeout = Nothing
, tracker = Nothing
}
sendTextMessage : ApiUrl -> ApiToken -> Int -> String -> String -> Cmd Msg
sendTextMessage apiUrl token transactionId room message = request
uploadMediaFile : ApiUrl -> ApiToken -> (File -> Result Http.Error String -> Msg) -> File -> Cmd Msg
uploadMediaFile apiUrl token msg file = request
{ method = "POST"
, headers = authenticatedHeaders token
, url = Builder.crossOrigin (fullMediaUrl apiUrl) [ "upload" ] [ Builder.string "filename" (name file) ]
, body = fileBody file
, expect = expectJson (msg file) <| Json.Decode.field "content_uri" Json.Decode.string
, timeout = Nothing
, tracker = Nothing
}
getHistory : ApiUrl -> ApiToken -> RoomId -> String -> Cmd Msg
getHistory apiUrl token room prevBatch = request
{ method = "GET"
, headers = authenticatedHeaders token
, url = (fullClientUrl apiUrl) ++ "/rooms/" ++ room ++ "/messages" ++ "?from=" ++ prevBatch ++ "&dir=" ++ "b"
, body = emptyBody
, expect = expectJson (ReceiveHistoryResponse room) historyResponseDecoder
, timeout = Nothing
, tracker = Nothing
}
sendMessage : ApiUrl -> ApiToken -> Int -> RoomId -> (Result Http.Error String -> Msg) -> List (String, Json.Encode.Value) -> Cmd Msg
sendMessage apiUrl token transactionId room msg contents = request
{ method = "PUT"
, headers = authenticatedHeaders token
, url = (fullUrl apiUrl)
, url = (fullClientUrl apiUrl)
++ "/rooms/" ++ room
++ "/send/" ++ "m.room.message"
++ "/" ++ (String.fromInt transactionId)
, body = jsonBody <| object
[ ("msgtype", string "m.text")
, ("body", string message)
]
, expect = expectWhatever SendRoomTextResponse
, body = jsonBody <| object contents
, expect = expectJson msg (field "event_id" Decode.string)
, timeout = Nothing
, tracker = Nothing
}
sendMarkdownMessage : ApiUrl -> ApiToken -> Int -> RoomId -> String -> String -> Cmd Msg
sendMarkdownMessage apiUrl token transactionId room message md = sendMessage apiUrl token transactionId room (SendRoomTextResponse transactionId)
[ ("msgtype", string "m.text")
, ("body", string message)
, ("formatted_body", string md)
, ("format", string "org.matrix.custom.html")
]
sendTextMessage : ApiUrl -> ApiToken -> Int -> RoomId -> String -> Cmd Msg
sendTextMessage apiUrl token transactionId room message = sendMessage apiUrl token transactionId room (SendRoomTextResponse transactionId)
[ ("msgtype", string "m.text")
, ("body", string message)
]
sendImageMessage : ApiUrl -> ApiToken -> Int -> RoomId -> File -> String -> Cmd Msg
sendImageMessage apiUrl token transactionId room file message = sendMessage apiUrl token transactionId room SendImageResponse
[ ("msgtype", string "m.image")
, ("body", string <| name file)
, ("url", string message)
, ("info", object [ ("mimetype", string <| mime file) ])
]
sendFileMessage : ApiUrl -> ApiToken -> Int -> RoomId -> File -> String -> Cmd Msg
sendFileMessage apiUrl token transactionId room file message = sendMessage apiUrl token transactionId room SendFileResponse
[ ("msgtype", string "m.file")
, ("body", string <| name file)
, ("url", string message)
, ("info", object [ ("mimetype", string <| mime file) ])
]
login : ApiUrl -> Username -> Password -> Cmd Msg
login apiUrl username password = request
{ method = "POST"
, headers = basicHeaders
, url = (fullUrl apiUrl) ++ "/login"
, url = (fullClientUrl apiUrl) ++ "/login"
, body = jsonBody <| object
[ ("type", string "m.login.password")
, ("identifier", object
@@ -62,7 +133,57 @@ login apiUrl username password = request
] )
, ("password", string password)
]
, expect = expectJson ReceiveLoginResponse loginResponseDecoder
, expect = expectJson (ReceiveLoginResponse apiUrl) loginResponseDecoder
, timeout = Nothing
, tracker = Nothing
}
getUserData : ApiUrl -> ApiToken -> Username -> Cmd Msg
getUserData apiUrl token username = request
{ method = "GET"
, headers = authenticatedHeaders token
, url = (fullClientUrl apiUrl) ++ "/profile/" ++ username
, body = emptyBody
, expect = expectJson (ReceiveUserData username) userDataDecoder
, timeout = Nothing
, tracker = Nothing
}
setReadMarkers : ApiUrl -> ApiToken -> RoomId -> String -> Maybe String -> Cmd Msg
setReadMarkers apiUrl token roomId fullyRead readReceipt =
let
readReciptFields = case readReceipt of
Just s -> [ ("m.read", string s) ]
_ -> []
in
request
{ method = "POST"
, headers = authenticatedHeaders token
, url = (fullClientUrl apiUrl) ++ "/rooms/" ++ roomId ++ "/read_markers"
, body = jsonBody <| object <| [ ("m.fully_read", string fullyRead) ] ++ readReciptFields
, expect = expectWhatever ReceiveCompletedReadMarker
, timeout = Nothing
, tracker = Nothing
}
sendTypingIndicator : ApiUrl -> ApiToken -> RoomId -> Username -> Bool -> Int -> Cmd Msg
sendTypingIndicator apiUrl token room user isTyping timeout = request
{ method = "PUT"
, headers = authenticatedHeaders token
, url = (fullClientUrl apiUrl) ++ "/rooms/" ++ room ++ "/typing/" ++ user
, body = jsonBody <| object [ ("timeout", int timeout), ("typing", bool isTyping) ]
, expect = expectWhatever ReceiveCompletedTypingIndicator
, timeout = Nothing
, tracker = Nothing
}
setRoomAccountData : ApiUrl -> ApiToken -> Username -> RoomId -> String -> Decode.Value -> Msg -> Cmd Msg
setRoomAccountData apiUrl token user roomId key value msg = request
{ method = "PUT"
, headers = authenticatedHeaders token
, url = (fullClientUrl apiUrl) ++ "/user/" ++ user ++ "/rooms/" ++ roomId ++ "/account_data/" ++ key
, body = jsonBody value
, expect = expectWhatever (\_ -> msg)
, timeout = Nothing
, tracker = Nothing
}

39
src/Scylla/ListUtils.elm Normal file
View File

@@ -0,0 +1,39 @@
module Scylla.ListUtils exposing (..)
import Dict exposing (Dict)
import Set exposing (Set)
groupBy : (a -> comparable) -> List a -> Dict comparable (List a)
groupBy f xs =
let
update v ml = case ml of
Just l -> Just (v::l)
Nothing -> Just [ v ]
in
List.foldl (\v acc -> Dict.update (f v) (update v) acc) Dict.empty xs
uniqueByTailRecursive : (a -> comparable) -> List a -> Set comparable -> List a -> List a
uniqueByTailRecursive f l s acc =
case l of
x::tail ->
if Set.member (f x) s
then uniqueByTailRecursive f tail s acc
else uniqueByTailRecursive f tail (Set.insert (f x) s) (x::acc)
[] -> List.reverse acc
uniqueBy : (a -> comparable) -> List a -> List a
uniqueBy f l = uniqueByTailRecursive f l Set.empty []
findFirst : (a -> Bool) -> List a -> Maybe a
findFirst cond l = case l of
x::xs -> if cond x then Just x else findFirst cond xs
[] -> Nothing
findLast : (a -> Bool) -> List a -> Maybe a
findLast cond l = findFirst cond <| List.reverse l
findFirstBy : (a -> comparable) -> (a -> Bool) -> List a -> Maybe a
findFirstBy sortFunction cond l = findFirst cond <| List.sortBy sortFunction l
findLastBy : (a -> comparable) -> (a -> Bool) -> List a -> Maybe a
findLastBy sortFunction cond l = findLast cond <| List.sortBy sortFunction l

View File

@@ -1,11 +1,32 @@
module Scylla.Login exposing (..)
import Scylla.Api exposing (ApiToken)
import Scylla.Api exposing (ApiUrl, ApiToken)
import Json.Decode as Decode exposing (Decoder, int, string, float, list, value, dict, bool)
import Json.Decode.Pipeline exposing (required, optional)
import Json.Encode as Encode
type alias Username = String
type alias Password = String
type alias LoginInfo =
{ token : ApiToken
, apiUrl : ApiUrl
, username : Username
, transactionId : Int
}
encodeLoginInfo : LoginInfo -> String
encodeLoginInfo {token, apiUrl, username, transactionId} =
token ++ "," ++ apiUrl ++ "," ++ username ++ "," ++ (String.fromInt transactionId)
decodeLoginInfo : String -> Maybe LoginInfo
decodeLoginInfo s = case String.indexes "," s of
[ fst, snd, thd ] -> Just <| LoginInfo
(String.slice 0 fst s)
(String.slice (fst + 1) snd s)
(String.slice (snd + 1) thd s)
(Maybe.withDefault 0 <| String.toInt <| String.dropLeft (thd + 1) s)
_ -> Nothing
type alias LoginResponse =
{ userId : String
, accessToken : ApiToken

15
src/Scylla/Markdown.elm Normal file
View File

@@ -0,0 +1,15 @@
port module Scylla.Markdown exposing (..)
type alias MarkdownRequest =
{ roomId : String
, text : String
}
type alias MarkdownResponse =
{ roomId : String
, text : String
, markdown : String
}
port requestMarkdownPort : MarkdownRequest -> Cmd msg
port receiveMarkdownPort : (MarkdownResponse -> msg) -> Sub msg

48
src/Scylla/Messages.elm Normal file
View File

@@ -0,0 +1,48 @@
module Scylla.Messages exposing (..)
import Scylla.Sync.Events exposing (RoomEvent, MessageEvent, toMessageEvent)
import Scylla.Login exposing (Username)
import Scylla.Route exposing (RoomId)
import Scylla.Room exposing (RoomData)
import Dict exposing (Dict)
type SendingMessageBody = TextMessage String
type alias SendingMessage =
{ body : SendingMessageBody
, id : Maybe String
}
type Message
= Sending SendingMessage
| Received MessageEvent
getUsername : Username -> Message -> Username
getUsername u msg = case msg of
Sending _ -> u
Received re -> re.sender
groupMessages : Username -> List Message -> List (Username, List Message)
groupMessages du xs =
let
initialState = (Nothing, [], [])
appendNamed mu ms msl = case mu of
Just u -> msl ++ [(u, ms)]
Nothing -> msl
foldFunction msg (pu, ms, msl) =
let
nu = Just <| getUsername du msg
in
if pu == nu then (pu, ms ++ [msg], msl) else (nu, [msg], appendNamed pu ms msl)
(fmu, fms, fmsl) = List.foldl foldFunction initialState xs
in
appendNamed fmu fms fmsl
getReceivedMessages : RoomData -> List Message
getReceivedMessages rd = rd.messages
|> List.filter (\e -> e.type_ == "m.room.message")
|> List.map Received
getSendingMessages : RoomId -> Dict Int (RoomId, SendingMessage) -> List Message
getSendingMessages rid ms = List.map (\(tid, (_, sm)) -> Sending sm)
<| List.filter (\(_, (nrid, _)) -> nrid == rid)
<| Dict.toList ms

View File

@@ -1,10 +1,25 @@
module Scylla.Model exposing (..)
import Scylla.Api exposing (..)
import Scylla.Sync exposing (SyncResponse, JoinedRoom)
import Scylla.Room exposing (getLocalDisplayName)
import Scylla.Sync exposing (SyncResponse, HistoryResponse)
import Scylla.ListUtils exposing (findFirst)
import Scylla.Room exposing (OpenRooms)
import Scylla.UserData exposing (UserData, getSenderName)
import Scylla.Sync.Rooms exposing (JoinedRoom)
import Scylla.Sync.Push exposing (Ruleset)
import Scylla.Sync.AccountData exposing (AccountData, directMessagesDecoder)
import Scylla.Login exposing (LoginResponse, Username, Password)
import Scylla.Route exposing (Route)
import Scylla.Route exposing (Route(..), RoomId)
import Scylla.Messages exposing (..)
import Scylla.Storage exposing (..)
import Scylla.Markdown exposing (..)
import Browser.Navigation as Nav
import Browser.Dom exposing (Viewport)
import Url.Builder
import Dict exposing (Dict)
import Time exposing (Posix)
import File exposing (File)
import Json.Decode as Decode
import Browser
import Http
import Url exposing (Url)
@@ -16,10 +31,15 @@ type alias Model =
, loginUsername : Username
, loginPassword : Password
, apiUrl : ApiUrl
, sync : SyncResponse
, accountData : AccountData
, nextBatch : String
, errors : List String
, roomText : Dict String String
, roomText : Dict RoomId String
, sending : Dict Int (RoomId, SendingMessage)
, transactionId : Int
, connected : Bool
, searchText : String
, rooms : OpenRooms
}
type Msg =
@@ -28,10 +48,49 @@ type Msg =
| ChangeLoginPassword Password -- During login screen: the password
| AttemptLogin -- During login screen, login button presed
| TryUrl Browser.UrlRequest -- User attempts to change URL
| OpenRoom String -- We try open a room
| ChangeRoute Route -- URL changes
| ChangeRoomText String String -- Change to a room's input text
| SendRoomText String -- Sends a message typed into a given room's input
| SendRoomTextResponse (Result Http.Error ()) -- A send message response finished
| SendRoomTextResponse Int (Result Http.Error String) -- A send message response finished
| ViewportAfterMessage (Result Browser.Dom.Error Viewport) -- A message has been received, try scroll (maybe)
| ViewportChangeComplete (Result Browser.Dom.Error ()) -- We're done changing the viewport.
| ReceiveFirstSyncResponse (Result Http.Error SyncResponse) -- HTTP, Sync has finished
| ReceiveSyncResponse (Result Http.Error SyncResponse) -- HTTP, Sync has finished
| ReceiveLoginResponse (Result Http.Error LoginResponse) -- HTTP, Login has finished
| ReceiveLoginResponse ApiUrl (Result Http.Error LoginResponse) -- HTTP, Login has finished
| ReceiveUserData Username (Result Http.Error UserData) -- HTTP, receive user data
| ReceiveCompletedReadMarker (Result Http.Error ()) -- HTTP, read marker request completed
| ReceiveCompletedTypingIndicator (Result Http.Error ()) -- HTTP, typing indicator request completed
| ReceiveStoreData Decode.Value -- We are send back a value on request from localStorage.
| TypingTick Posix -- Tick for updating the typing status
| History RoomId -- Load history for a room
| ReceiveHistoryResponse RoomId (Result Http.Error HistoryResponse) -- HTTP, receive history
| SendImages RoomId -- Image selection triggered
| SendFiles RoomId -- File selection triggered
| ImagesSelected RoomId File (List File) -- Images to send selected
| FilesSelected RoomId File (List File) -- Files to send selected
| ImageUploadComplete RoomId File (Result Http.Error String) -- Image has been uploaded
| FileUploadComplete RoomId File (Result Http.Error String) -- File has been uploaded
| SendImageResponse (Result Http.Error String) -- Server responded to image
| SendFileResponse (Result Http.Error String) -- Server responded to file
| ReceiveMarkdown MarkdownResponse -- Markdown was rendered
| DismissError Int -- User dismisses error
| AttemptReconnect -- User wants to reconnect to server
| UpdateSearchText String -- Change search text in room list
roomUrl : String -> String
roomUrl s = Url.Builder.absolute [ "room", s ] []
loginUrl : String
loginUrl = Url.Builder.absolute [ "login" ] []
currentRoomId : Model -> Maybe RoomId
currentRoomId m = case m.route of
Room r -> Just r
_ -> Nothing
roomLocalDisplayName : Model -> RoomId -> Username -> String
roomLocalDisplayName m rid u =
case Dict.get rid m.rooms of
Just rd -> getLocalDisplayName rd u
_ -> getSenderName u

View File

@@ -0,0 +1,32 @@
port module Scylla.Notification exposing (..)
import Scylla.Sync exposing (SyncResponse, joinedRoomsTimelineEvents)
import Scylla.Sync.Events exposing (RoomEvent, MessageEvent, toMessageEvent)
import Scylla.Sync.Push exposing (Ruleset, getEventNotification)
import Json.Decode as Decode exposing (string, field)
import Dict
type alias Notification =
{ name : String
, text : String
, room : String
}
port sendNotificationPort : Notification -> Cmd msg
port onNotificationClickPort : (String -> msg) -> Sub msg
getText : MessageEvent -> String
getText re = case (Decode.decodeValue (field "msgtype" string) re.content) of
Ok "m.text" -> Result.withDefault "" <| (Decode.decodeValue (field "body" string) re.content)
_ -> ""
getNotificationEvents : Ruleset -> SyncResponse -> List (String, MessageEvent)
getNotificationEvents rs s = s.rooms
|> Maybe.andThen .join
|> Maybe.map (Dict.map (\k v -> v.timeline
|> Maybe.andThen .events
|> Maybe.map (List.filter <| getEventNotification rs k)
|> Maybe.map (List.filterMap <| toMessageEvent)
|> Maybe.withDefault []))
|> Maybe.withDefault Dict.empty
|> Dict.toList
|> List.concatMap (\(k, vs) -> List.map (\v -> (k, v)) vs)

187
src/Scylla/Room.elm Normal file
View File

@@ -0,0 +1,187 @@
module Scylla.Room exposing (..)
import Scylla.Route exposing (RoomId)
import Scylla.Sync exposing (SyncResponse)
import Scylla.Login exposing (Username)
import Scylla.UserData exposing (getSenderName)
import Scylla.Sync exposing (HistoryResponse)
import Scylla.Sync.Events exposing (MessageEvent, StateEvent, toStateEvent, toMessageEvent)
import Scylla.Sync.AccountData exposing (AccountData, getDirectMessages, applyAccountData)
import Scylla.Sync.Rooms exposing (JoinedRoom, UnreadNotificationCounts, Ephemeral)
import Scylla.ListUtils exposing (findFirst, uniqueBy)
import Json.Decode as Decode exposing (Decoder, Value, decodeValue, field, string, list)
import Dict exposing (Dict)
type alias RoomState = Dict (String, String) Value
type alias RoomData =
{ roomState : RoomState
, messages : List (MessageEvent)
, accountData : AccountData
, ephemeral : Ephemeral
, unreadNotifications : UnreadNotificationCounts
, prevHistoryBatch : Maybe String
, text : String
}
type alias OpenRooms = Dict RoomId RoomData
emptyOpenRooms : OpenRooms
emptyOpenRooms = Dict.empty
emptyRoomData : RoomData
emptyRoomData =
{ roomState = Dict.empty
, messages = []
, accountData = { events = Just [] }
, ephemeral = { events = Just [] }
, unreadNotifications =
{ highlightCount = Just 0
, notificationCount = Just 0
}
, prevHistoryBatch = Nothing
, text = ""
}
changeRoomStateEvent : StateEvent -> RoomState -> RoomState
changeRoomStateEvent se = Dict.insert (se.type_, se.stateKey) se.content
changeRoomStateEvents : List StateEvent -> RoomState -> RoomState
changeRoomStateEvents es rs = List.foldr (changeRoomStateEvent) rs es
changeRoomState : JoinedRoom -> RoomState -> RoomState
changeRoomState jr rs =
let
stateDiff = jr.state
|> Maybe.andThen .events
|> Maybe.withDefault []
timelineDiff = jr.timeline
|> Maybe.andThen .events
|> Maybe.map (List.filterMap toStateEvent)
|> Maybe.withDefault []
in
rs
|> changeRoomStateEvents stateDiff
|> changeRoomStateEvents timelineDiff
changeTimeline : JoinedRoom -> List (MessageEvent) -> List (MessageEvent)
changeTimeline jr tl =
let
newMessages = jr.timeline
|> Maybe.andThen .events
|> Maybe.map (List.filterMap toMessageEvent)
|> Maybe.withDefault []
in
tl ++ newMessages
changeEphemeral : JoinedRoom -> Ephemeral -> Ephemeral
changeEphemeral jr e = Maybe.withDefault e jr.ephemeral
changeNotifications : JoinedRoom -> UnreadNotificationCounts -> UnreadNotificationCounts
changeNotifications jr un = Maybe.withDefault un jr.unreadNotifications
changeRoomData : JoinedRoom -> RoomData -> RoomData
changeRoomData jr rd =
{ rd | accountData = applyAccountData jr.accountData rd.accountData
, roomState = changeRoomState jr rd.roomState
, messages = changeTimeline jr rd.messages
, ephemeral = changeEphemeral jr rd.ephemeral
, unreadNotifications = changeNotifications jr rd.unreadNotifications
, prevHistoryBatch =
case rd.prevHistoryBatch of
Nothing -> Maybe.andThen .prevBatch jr.timeline
Just _ -> rd.prevHistoryBatch
}
updateRoomData : JoinedRoom -> Maybe RoomData -> Maybe RoomData
updateRoomData jr mrd = Maybe.withDefault emptyRoomData mrd
|> changeRoomData jr
|> Just
applyJoinedRoom : RoomId -> JoinedRoom -> OpenRooms -> OpenRooms
applyJoinedRoom rid jr = Dict.update rid (updateRoomData jr)
applySync : SyncResponse -> OpenRooms -> OpenRooms
applySync sr or =
let
joinedRooms = sr.rooms
|> Maybe.andThen .join
|> Maybe.withDefault Dict.empty
in
Dict.foldl applyJoinedRoom or joinedRooms
addHistoryRoomData : HistoryResponse -> Maybe RoomData -> Maybe RoomData
addHistoryRoomData hr = Maybe.map
(\rd ->
{ rd | messages = uniqueBy .eventId
<| (List.reverse <| List.filterMap toMessageEvent hr.chunk) ++ rd.messages
, prevHistoryBatch = Just hr.end
})
applyHistoryResponse : RoomId -> HistoryResponse -> OpenRooms -> OpenRooms
applyHistoryResponse rid hr = Dict.update rid (addHistoryRoomData hr)
getStateData : (String, String) -> Decoder a -> RoomData -> Maybe a
getStateData k d rd = Dict.get k rd.roomState
|> Maybe.andThen (Result.toMaybe << decodeValue d)
getEphemeralData : String -> Decoder a -> RoomData -> Maybe a
getEphemeralData k d rd = rd.ephemeral.events
|> Maybe.andThen (findFirst ((==) k << .type_))
|> Maybe.andThen (Result.toMaybe << decodeValue d << .content)
getRoomTypingUsers : RoomData -> List String
getRoomTypingUsers = Maybe.withDefault []
<< getEphemeralData "m.typing" (field "user_ids" (list string))
getRoomName : AccountData -> RoomId -> RoomData -> String
getRoomName ad rid rd =
let
customName = getStateData ("m.room.name", "") (field "name" (string)) rd
direct = getDirectMessages ad
|> Maybe.andThen (Dict.get rid)
in
case (customName, direct) of
(Just cn, _) -> cn
(_, Just d) -> getLocalDisplayName rd d
_ -> rid
getLocalDisplayName : RoomData -> Username -> String
getLocalDisplayName rd u =
getStateData ("m.room.member", u) (field "displayname" string) rd
|> Maybe.withDefault (getSenderName u)
getNotificationCount : RoomData -> (Int, Int)
getNotificationCount rd =
( Maybe.withDefault 0 rd.unreadNotifications.notificationCount
, Maybe.withDefault 0 rd.unreadNotifications.highlightCount
)
getTotalNotificationCount : OpenRooms -> (Int, Int)
getTotalNotificationCount =
let
sumTuples (x1, y1) (x2, y2) = (x1+x2, y1+y2)
in
Dict.foldl (\_ -> sumTuples << getNotificationCount) (0, 0)
getTotalNotificationCountString : OpenRooms -> Maybe String
getTotalNotificationCountString or =
let
(n, h) = getTotalNotificationCount or
suffix = case h of
0 -> ""
_ -> "!"
in
case n of
0 -> Nothing
_ -> Just <| "(" ++ String.fromInt n ++ suffix ++ ")"
getHomeserver : String -> String
getHomeserver s =
let
colonIndex = Maybe.withDefault 0
<| Maybe.map ((+) 1)
<| List.head
<| String.indexes ":" s
in
String.dropLeft colonIndex s

18
src/Scylla/Storage.elm Normal file
View File

@@ -0,0 +1,18 @@
port module Scylla.Storage exposing (..)
import Json.Encode
import Json.Decode as Decode exposing (Decoder, int, string, float, list, value, dict, bool)
import Json.Decode.Pipeline exposing (required, optional)
type alias StoreData =
{ key : String
, value: Decode.Value
}
storeDataDecoder : Decoder StoreData
storeDataDecoder = Decode.succeed StoreData
|> required "key" string
|> required "value" value
port setStoreValuePort : (String, Json.Encode.Value) -> Cmd msg
port getStoreValuePort : (String) -> Cmd msg
port receiveStoreValuePort : (Json.Encode.Value -> msg) -> Sub msg

View File

@@ -1,236 +1,17 @@
module Scylla.Sync exposing (..)
import Scylla.Api exposing (..)
import Scylla.Login exposing (Username)
import Scylla.Route exposing (RoomId)
import Scylla.ListUtils exposing (..)
import Scylla.Sync.DecodeTools exposing (maybeDecode)
import Scylla.Sync.Events exposing (..)
import Scylla.Sync.Rooms exposing (..)
import Scylla.Sync.AccountData exposing (..)
import Dict exposing (Dict)
import Json.Decode as Decode exposing (Decoder, int, string, float, list, value, dict, bool, field)
import Json.Decode.Pipeline exposing (required, optional)
import Set exposing (Set)
-- Special Decoding
decodeJust : Decoder a -> Decoder (Maybe a)
decodeJust = Decode.map Just
maybeDecode : String -> Decoder a -> Decoder (Maybe a -> b) -> Decoder b
maybeDecode s d = optional s (decodeJust d) Nothing
-- General Events
type alias Event =
{ content : Decode.Value
, type_ : String
}
eventDecoder : Decoder Event
eventDecoder =
Decode.succeed Event
|> required "content" value
|> required "type" string
type alias EventContent =
{ avatarUrl : Maybe String
, displayname : Maybe String
, membership : String
, isDirect : Maybe Bool
-- , thirdPartyInvite : Invite
, unsigned : Maybe UnsignedData
}
eventContentDecoder : Decoder EventContent
eventContentDecoder =
Decode.succeed EventContent
|> maybeDecode "avatar_url" string
|> maybeDecode "displayname" string
|> required "membership" string
|> maybeDecode "is_direct" bool
-- |> required "third_party_invite" inviteDecoder
|> maybeDecode "unsigned" unsignedDataDecoder
-- Unsigned Data
type alias UnsignedData =
{ age : Maybe Int
, redactedBecause : Maybe Event
, transactionId : Maybe String
}
unsignedDataDecoder : Decoder UnsignedData
unsignedDataDecoder =
Decode.succeed UnsignedData
|> maybeDecode "age" int
|> maybeDecode "redacted_because" eventDecoder
|> maybeDecode "transaction_id" string
-- State
type alias State =
{ events : Maybe (List StateEvent)
}
stateDecoder : Decoder State
stateDecoder =
Decode.succeed State
|> maybeDecode "events" (list stateEventDecoder)
type alias StateEvent =
{ content : Decode.Value
, type_ : String
, eventId : String
, sender : String
, originServerTs : Int
, unsigned : Maybe UnsignedData
, prevContent : Maybe EventContent
, stateKey : String
}
stateEventDecoder : Decoder StateEvent
stateEventDecoder =
Decode.succeed StateEvent
|> required "content" value
|> required "type" string
|> required "event_id" string
|> required "sender" string
|> required "origin_server_ts" int
|> maybeDecode "unsigned" unsignedDataDecoder
|> maybeDecode "prev_content" eventContentDecoder
|> required "state_key" string
-- Rooms
type alias Rooms =
{ join : Maybe (Dict String JoinedRoom)
, invite : Maybe (Dict String InvitedRoom)
, leave : Maybe (Dict String LeftRoom)
}
roomsDecoder : Decoder Rooms
roomsDecoder =
Decode.succeed Rooms
|> maybeDecode "join" (dict joinedRoomDecoder)
|> maybeDecode "invite" (dict invitedRoomDecoder)
|> maybeDecode "leave" (dict leftRoomDecoder)
type alias JoinedRoom =
{ state : Maybe State
, timeline : Maybe Timeline
, ephemeral : Maybe Ephemeral
, accountData : Maybe AccountData
, unreadNotifications : Maybe UnreadNotificationCounts
}
joinedRoomDecoder : Decoder JoinedRoom
joinedRoomDecoder =
Decode.succeed JoinedRoom
|> maybeDecode "state" stateDecoder
|> maybeDecode "timeline" timelineDecoder
|> maybeDecode "ephemeral" ephemeralDecoder
|> maybeDecode "account_data" accountDataDecoder
|> maybeDecode "unread_notifications" unreadNotificationCountsDecoder
-- Joined Room Data
type alias Timeline =
{ events : Maybe (List RoomEvent)
, limited : Maybe Bool
, prevBatch : Maybe String
}
timelineDecoder =
Decode.succeed Timeline
|> maybeDecode "events" (list roomEventDecoder)
|> maybeDecode "limited" bool
|> maybeDecode "prev_batch" string
type alias RoomEvent =
{ content : Decode.Value
, type_ : String
, eventId : String
, sender : String
, originServerTs : Int
, unsigned : Maybe UnsignedData
}
roomEventDecoder : Decoder RoomEvent
roomEventDecoder =
Decode.succeed RoomEvent
|> required "content" value
|> required "type" string
|> required "event_id" string
|> required "sender" string
|> required "origin_server_ts" int
|> maybeDecode "unsigned" unsignedDataDecoder
type alias Ephemeral =
{ events : Maybe (List Event)
}
ephemeralDecoder : Decoder Ephemeral
ephemeralDecoder =
Decode.succeed Ephemeral
|> maybeDecode "events" (list eventDecoder)
type alias AccountData =
{ events : Maybe (List Event)
}
accountDataDecoder : Decoder AccountData
accountDataDecoder =
Decode.succeed AccountData
|> maybeDecode "events" (list eventDecoder)
type alias UnreadNotificationCounts =
{ highlightCount : Maybe Int
, notificationCount : Maybe Int
}
unreadNotificationCountsDecoder : Decoder UnreadNotificationCounts
unreadNotificationCountsDecoder =
Decode.succeed UnreadNotificationCounts
|> maybeDecode "highlight_count" int
|> maybeDecode "notification_count" int
-- Invited Room Data
type alias InvitedRoom =
{ inviteState : Maybe InviteState
}
invitedRoomDecoder : Decoder InvitedRoom
invitedRoomDecoder =
Decode.succeed InvitedRoom
|> maybeDecode "invite_state" inviteStateDecoder
type alias InviteState =
{ events : Maybe (List StrippedState)
}
inviteStateDecoder : Decoder InviteState
inviteStateDecoder =
Decode.succeed InviteState
|> maybeDecode "events" (list strippedStateDecoder)
type alias StrippedState =
{ content : EventContent
, stateKey : String
, type_ : String
, sender : String
}
strippedStateDecoder : Decoder StrippedState
strippedStateDecoder =
Decode.succeed StrippedState
|> required "content" eventContentDecoder
|> required "state_key" string
|> required "type" string
|> required "sender" string
-- Left Room Data
type alias LeftRoom =
{ state : Maybe State
, timeline : Maybe Timeline
, accountData : Maybe AccountData
}
leftRoomDecoder : Decoder LeftRoom
leftRoomDecoder =
Decode.succeed LeftRoom
|> maybeDecode "state" stateDecoder
|> maybeDecode "timeline" timelineDecoder
|> maybeDecode "account_data" accountDataDecoder
-- General Sync Response
type alias SyncResponse =
{ nextBatch : String
@@ -256,102 +37,53 @@ presenceDecoder =
Decode.succeed Presence
|> maybeDecode "events" (list eventDecoder)
-- Business Logic
uniqueByRecursive : (a -> comparable) -> List a -> Set comparable -> List a
uniqueByRecursive f l s = case l of
x::tail -> if Set.member (f x) s
then uniqueByRecursive f tail s
else x::uniqueByRecursive f tail (Set.insert (f x) s)
[] -> []
-- Room History Responses
type alias HistoryResponse =
{ start : String
, end : String
, chunk : List RoomEvent
}
uniqueBy : (a -> comparable) -> List a -> List a
uniqueBy f l = uniqueByRecursive f l Set.empty
historyResponseDecoder : Decoder HistoryResponse
historyResponseDecoder =
Decode.succeed HistoryResponse
|> required "start" string
|> required "end" string
|> required "chunk" (list roomEventDecoder)
mergeMaybe : (a -> a -> a) -> Maybe a -> Maybe a -> Maybe a
mergeMaybe f l r = case (l, r) of
(Just v1, Just v2) -> Just <| f v1 v2
(Just v, Nothing) -> Just v
(Nothing, Just v) -> Just v
_ -> Nothing
-- Business Logic: Helper Functions
findFirstEvent : ({ a | originServerTs : Int } -> Bool) -> List { a | originServerTs : Int } -> Maybe { a | originServerTs : Int }
findFirstEvent = findFirstBy .originServerTs
mergeEvents : List Event -> List Event -> List Event
mergeEvents l1 l2 = l1 ++ l2
findLastEvent : ({ a | originServerTs : Int } -> Bool) -> List { a | originServerTs : Int } -> Maybe { a | originServerTs : Int }
findLastEvent = findLastBy .originServerTs
mergeStateEvents : List StateEvent -> List StateEvent -> List StateEvent
mergeStateEvents l1 l2 = uniqueBy .eventId <| l1 ++ l2
-- Business Logic: Events
allRoomDictTimelineEvents : Dict String { a | timeline : Maybe Timeline } -> List RoomEvent
allRoomDictTimelineEvents dict = List.concatMap (Maybe.withDefault [] << .events)
<| List.filterMap .timeline
<| Dict.values dict
mergeRoomEvents : List RoomEvent -> List RoomEvent -> List RoomEvent
mergeRoomEvents l1 l2 = uniqueBy .eventId <| l1 ++ l2
allTimelineEventIds : SyncResponse -> List String
allTimelineEventIds s = List.map getEventId <| allTimelineEvents s
mergeStrippedStates : List StrippedState -> List StrippedState -> List StrippedState
mergeStrippedStates l1 l2 = l1 ++ l2
mergeAccountData : AccountData -> AccountData -> AccountData
mergeAccountData a1 a2 = AccountData <| mergeMaybe mergeEvents a1.events a2.events
mergePresence : Presence -> Presence -> Presence
mergePresence p1 p2 = Presence <| mergeMaybe mergeEvents p1.events p2.events
mergeDicts : (b -> b -> b) -> Dict comparable b -> Dict comparable b -> Dict comparable b
mergeDicts f d1 d2 =
allTimelineEvents : SyncResponse -> List RoomEvent
allTimelineEvents s =
let
inOne = Dict.insert
inBoth k v1 v2 = Dict.insert k (f v1 v2)
eventsFor f = Maybe.withDefault []
<| Maybe.map allRoomDictTimelineEvents
<| Maybe.andThen f s.rooms
joinedEvents = eventsFor .join
leftEvents = eventsFor .leave
in
Dict.merge inOne inBoth inOne d1 d2 (Dict.empty)
leftEvents ++ joinedEvents
mergeState : State -> State -> State
mergeState s1 s2 = State <| mergeMaybe mergeStateEvents s1.events s2.events
joinedRoomsTimelineEvents : SyncResponse -> Dict String (List RoomEvent)
joinedRoomsTimelineEvents s =
Maybe.withDefault Dict.empty
<| Maybe.map (Dict.map (\k v -> Maybe.withDefault [] <| Maybe.andThen .events v.timeline))
<| Maybe.andThen .join s.rooms
mergeTimeline : Timeline -> Timeline -> Timeline
mergeTimeline t1 t2 = Timeline (mergeMaybe mergeRoomEvents t1.events t2.events) Nothing t2.prevBatch
mergeJoinedRoom : JoinedRoom -> JoinedRoom -> JoinedRoom
mergeJoinedRoom r1 r2 =
{ r2 | state = mergeMaybe mergeState r1.state r2.state
, timeline = mergeMaybe mergeTimeline r1.timeline r2.timeline
, accountData = mergeMaybe mergeAccountData r1.accountData r2.accountData
}
mergeInviteState : InviteState -> InviteState -> InviteState
mergeInviteState i1 i2 = InviteState <| mergeMaybe mergeStrippedStates i1.events i2.events
mergeInvitedRoom : InvitedRoom -> InvitedRoom -> InvitedRoom
mergeInvitedRoom i1 i2 = InvitedRoom <| mergeMaybe mergeInviteState i1.inviteState i2.inviteState
mergeLeftRoom : LeftRoom -> LeftRoom -> LeftRoom
mergeLeftRoom l1 l2 = LeftRoom
(mergeMaybe mergeState l1.state l2.state)
(mergeMaybe mergeTimeline l1.timeline l2.timeline)
(mergeMaybe mergeAccountData l1.accountData l2.accountData)
mergeJoin : Dict String JoinedRoom -> Dict String JoinedRoom -> Dict String JoinedRoom
mergeJoin = mergeDicts mergeJoinedRoom
mergeInvite : Dict String InvitedRoom -> Dict String InvitedRoom -> Dict String InvitedRoom
mergeInvite = mergeDicts mergeInvitedRoom
mergeLeave : Dict String LeftRoom -> Dict String LeftRoom -> Dict String LeftRoom
mergeLeave = mergeDicts mergeLeftRoom
mergeRooms : Rooms -> Rooms -> Rooms
mergeRooms r1 r2 =
{ join = mergeMaybe mergeJoin r1.join r2.join
, invite = mergeMaybe mergeInvite r1.invite r2.invite
, leave = mergeMaybe mergeLeave r1.leave r2.leave
}
mergeSyncResponse : SyncResponse -> SyncResponse -> SyncResponse
mergeSyncResponse l r =
{ r | rooms = mergeMaybe mergeRooms l.rooms r.rooms
, accountData = mergeMaybe mergeAccountData l.accountData r.accountData
}
roomName : JoinedRoom -> Maybe String
roomName jr =
let
state = jr.state
nameEvent = List.head << List.sortBy (\e -> -e.originServerTs) << List.filter (\e -> e.type_ == "m.room.name")
name e = Result.toMaybe <| Decode.decodeValue (field "name" string) e.content
in
Maybe.andThen name <| Maybe.andThen nameEvent <| Maybe.andThen .events <| state
-- Business Logic: Users
allUsers : SyncResponse -> List Username
allUsers s = uniqueBy (\u -> u) <| List.map getSender <| allTimelineEvents s

View File

@@ -0,0 +1,54 @@
module Scylla.Sync.AccountData exposing (..)
import Scylla.ListUtils exposing (..)
import Scylla.Sync.DecodeTools exposing (maybeDecode)
import Scylla.Sync.Events exposing (Event, eventDecoder)
import Scylla.Sync.Push exposing (Ruleset, rulesetDecoder)
import Json.Decode as Decode exposing (Decoder, list, field, decodeValue)
import Dict exposing (Dict)
type alias AccountData =
{ events : Maybe (List Event)
}
accountDataDecoder : Decoder AccountData
accountDataDecoder =
Decode.succeed AccountData
|> maybeDecode "events" (list eventDecoder)
type alias DirectMessages = Dict String String
directMessagesDecoder : Decode.Decoder DirectMessages
directMessagesDecoder =
Decode.dict (Decode.list Decode.string)
|> Decode.map (invertDirectMessages)
type alias DirectMessagesRaw = Dict String (List String)
invertDirectMessages : DirectMessagesRaw -> DirectMessages
invertDirectMessages dmr =
Dict.foldl
(\k lv acc -> List.foldl (\v -> Dict.insert v k) acc lv)
Dict.empty
dmr
applyAccountData : Maybe AccountData -> AccountData -> AccountData
applyAccountData mad ad =
case mad of
Nothing -> ad
Just newAd ->
case (newAd.events, ad.events) of
(Just es, Nothing) -> newAd
(Just newEs, Just es) -> { events = Just (newEs ++ es) }
_ -> ad
getAccountData : String -> Decode.Decoder a -> AccountData -> Maybe a
getAccountData key d ad = ad.events
|> Maybe.andThen (findFirst ((==) key << .type_))
|> Maybe.map .content
|> Maybe.andThen (Result.toMaybe << decodeValue d)
getDirectMessages : AccountData -> Maybe DirectMessages
getDirectMessages = getAccountData "m.direct" directMessagesDecoder
getPushRuleset : AccountData -> Maybe Ruleset
getPushRuleset = getAccountData "m.push_rules" (field "global" rulesetDecoder)

View File

@@ -0,0 +1,9 @@
module Scylla.Sync.DecodeTools exposing (..)
import Json.Decode as Decode exposing (Decoder)
import Json.Decode.Pipeline exposing (optional)
decodeJust : Decoder a -> Decoder (Maybe a)
decodeJust = Decode.map Just
maybeDecode : String -> Decoder a -> Decoder (Maybe a -> b) -> Decoder b
maybeDecode s d = optional s (decodeJust d) Nothing

149
src/Scylla/Sync/Events.elm Normal file
View File

@@ -0,0 +1,149 @@
module Scylla.Sync.Events exposing (..)
import Scylla.Sync.DecodeTools exposing (maybeDecode)
import Json.Decode as Decode exposing (Decoder, int, string, value, oneOf)
import Json.Decode.Pipeline exposing (required)
type alias UnsignedData =
{ age : Maybe Int
, redactedBecause : Maybe Event
, transactionId : Maybe String
}
unsignedDataDecoder : Decoder UnsignedData
unsignedDataDecoder =
Decode.succeed UnsignedData
|> maybeDecode "age" int
|> maybeDecode "redacted_because" eventDecoder
|> maybeDecode "transaction_id" string
type alias EventContent = Decode.Value
eventContentDecoder : Decoder EventContent
eventContentDecoder = Decode.value
type alias Event =
{ content : Decode.Value
, type_ : String
}
eventDecoder : Decoder Event
eventDecoder =
Decode.succeed Event
|> required "content" value
|> required "type" string
type RoomEvent
= StateRoomEvent StateEvent
| MessageRoomEvent MessageEvent
roomEventDecoder : Decoder RoomEvent
roomEventDecoder = oneOf
[ Decode.map StateRoomEvent stateEventDecoder
, Decode.map MessageRoomEvent messageEventDecoder
]
type alias MessageEvent =
{ content : EventContent
, type_ : String
, eventId : String
, sender : String
, originServerTs : Int
, unsigned : Maybe UnsignedData
}
messageEventDecoder : Decoder MessageEvent
messageEventDecoder =
Decode.succeed MessageEvent
|> required "content" value
|> required "type" string
|> required "event_id" string
|> required "sender" string
|> required "origin_server_ts" int
|> maybeDecode "unsigned" unsignedDataDecoder
type alias StateEvent =
{ content : EventContent
, type_ : String
, eventId : String
, sender : String
, originServerTs : Int
, unsigned : Maybe UnsignedData
, prevContent : Maybe EventContent
, stateKey : String
}
stateEventDecoder : Decoder StateEvent
stateEventDecoder =
Decode.succeed StateEvent
|> required "content" value
|> required "type" string
|> required "event_id" string
|> required "sender" string
|> required "origin_server_ts" int
|> maybeDecode "unsigned" unsignedDataDecoder
|> maybeDecode "prev_content" eventContentDecoder
|> required "state_key" string
type alias StrippedStateEvent =
{ content : EventContent
, stateKey : String
, type_ : String
, sender : String
}
strippedStateEventDecoder : Decoder StrippedStateEvent
strippedStateEventDecoder =
Decode.succeed StrippedStateEvent
|> required "content" eventContentDecoder
|> required "state_key" string
|> required "type" string
|> required "sender" string
-- Operations on Room Events
getUnsigned : RoomEvent -> Maybe UnsignedData
getUnsigned re =
case re of
StateRoomEvent e -> e.unsigned
MessageRoomEvent e -> e.unsigned
getEventId : RoomEvent -> String
getEventId re =
case re of
StateRoomEvent e -> e.eventId
MessageRoomEvent e -> e.eventId
getSender : RoomEvent -> String
getSender re =
case re of
StateRoomEvent e -> e.sender
MessageRoomEvent e -> e.sender
getType : RoomEvent -> String
getType re =
case re of
StateRoomEvent e -> e.type_
MessageRoomEvent e -> e.type_
getContent : RoomEvent -> Decode.Value
getContent re =
case re of
StateRoomEvent e -> e.content
MessageRoomEvent e -> e.content
toStateEvent : RoomEvent -> Maybe StateEvent
toStateEvent re =
case re of
StateRoomEvent e -> Just e
_ -> Nothing
toMessageEvent : RoomEvent -> Maybe MessageEvent
toMessageEvent re =
case re of
MessageRoomEvent e -> Just e
_ -> Nothing
toEvent : RoomEvent -> Event
toEvent re =
case re of
StateRoomEvent e -> { content = e.content, type_ = e.type_ }
MessageRoomEvent e -> { content = e.content, type_ = e.type_ }

167
src/Scylla/Sync/Push.elm Normal file
View File

@@ -0,0 +1,167 @@
module Scylla.Sync.Push exposing (..)
import Scylla.Sync.DecodeTools exposing (maybeDecode)
import Scylla.Sync.Events exposing (RoomEvent, getSender, getContent, getType)
import Scylla.Route exposing (RoomId)
import Json.Decode as Decode exposing (Decoder, string, int, field, value, bool, list)
import Json.Decode.Pipeline exposing (required, optional)
type Condition
= EventMatch String String
| ContainsDisplayName
| RoomMemberCount Int
| SenderNotificationPermission String
conditionDecoder : Decoder Condition
conditionDecoder =
let
eventMatchDecoder =
Decode.succeed EventMatch
|> required "key" string
|> required "pattern" string
containsDisplayNameDecoder =
Decode.succeed ContainsDisplayName
roomMemberCountDecoder =
Decode.succeed RoomMemberCount
|> required "is"
(Decode.map (Maybe.withDefault 0 << String.toInt) string)
senderNotifPermissionDecoder =
Decode.succeed SenderNotificationPermission
|> required "key" string
dispatchDecoder k =
case k of
"event_match" -> eventMatchDecoder
"contains_display_name" -> containsDisplayNameDecoder
"room_member_count" -> roomMemberCountDecoder
"sender_notification_permission" -> senderNotifPermissionDecoder
_ -> Decode.fail "Unknown condition code"
in
field "kind" string
|> Decode.andThen dispatchDecoder
type Action
= Notify
| DontNotify
| Coalesce
| SetTweak String (Maybe Decode.Value)
actionDecoder : Decoder Action
actionDecoder =
let
dispatchStringDecoder s =
case s of
"notify" -> Decode.succeed Notify
"dont_notify" -> Decode.succeed DontNotify
"coalesce" -> Decode.succeed Coalesce
_ -> Decode.fail "Unknown action string"
objectDecoder =
Decode.succeed SetTweak
|> required "set_tweak" string
|> maybeDecode "value" value
in
Decode.oneOf
[ string |> Decode.andThen dispatchStringDecoder
, objectDecoder
]
type alias Rule =
{ ruleId : String
, default : Bool
, enabled : Bool
, conditions : List Condition
, actions : List Action
}
ruleDecoder : Decoder Rule
ruleDecoder =
let
patternDecoder = Decode.oneOf
[ field "pattern" string
|> Decode.andThen
(\p -> Decode.succeed <| \r ->
{ r | conditions = (EventMatch "content.body" p)::r.conditions })
, Decode.succeed identity
]
basicRuleDecoder = Decode.succeed Rule
|> required "rule_id" string
|> optional "default" bool True
|> optional "enabled" bool False
|> optional "conditions" (list conditionDecoder) []
|> required "actions" (list actionDecoder)
in
patternDecoder
|> Decode.andThen (\f -> Decode.map f basicRuleDecoder)
type alias Ruleset =
{ content : List Rule
, override : List Rule
, room : List Rule
, sender : List Rule
, underride : List Rule
}
rulesetDecoder : Decoder Ruleset
rulesetDecoder = Decode.succeed Ruleset
|> optional "content" (list ruleDecoder) []
|> optional "override" (list ruleDecoder) []
|> optional "room" (list ruleDecoder) []
|> optional "sender" (list ruleDecoder) []
|> optional "underride" (list ruleDecoder) []
checkCondition : RoomEvent -> Condition -> Bool
checkCondition re c =
let
pathDecoder xs p =
Decode.at xs string
|> Decode.map (String.contains p << String.toLower)
matchesPattern xs p =
case Decode.decodeValue (pathDecoder xs p) (getContent re) of
Ok True -> True
_ -> False
in
case c of
EventMatch k p ->
case String.split "." k of
"content"::xs -> matchesPattern xs p
"type"::[] -> String.contains p <| getType re
_ -> False
ContainsDisplayName -> False
RoomMemberCount _ -> False
SenderNotificationPermission _ -> False
applyAction : Action -> List Action -> List Action
applyAction a as_ =
case a of
Notify -> Notify :: List.filter (\a_ -> a_ /= DontNotify) as_
DontNotify -> DontNotify :: List.filter (\a_ -> a_ /= Notify) as_
Coalesce -> Coalesce :: List.filter (\a_ -> a_ /= DontNotify) as_
a_ -> a_ :: as_
applyActions : List Action -> List Action -> List Action
applyActions l r = List.foldl applyAction r l
updatePushRuleActions : Rule -> RoomEvent -> List Action -> List Action
updatePushRuleActions r re as_ =
if List.all (checkCondition re) r.conditions
then applyActions r.actions as_
else as_
updatePushActions : List Rule -> RoomEvent -> List Action -> List Action
updatePushActions rs re as_ =
List.filter .enabled rs
|> List.foldl (\r -> updatePushRuleActions r re) as_
getPushActions : Ruleset -> RoomId -> RoomEvent -> List Action
getPushActions rs rid re =
let
roomRules = List.filter (((==) rid) << .ruleId) rs.room
senderRules = List.filter (((==) <| getSender re) << .ruleId) rs.sender
in
updatePushActions rs.underride re []
|> updatePushActions senderRules re
|> updatePushActions roomRules re
|> updatePushActions rs.override re
getEventNotification : Ruleset -> RoomId -> RoomEvent -> Bool
getEventNotification rs rid re =
getPushActions rs rid re
|> List.member Notify

109
src/Scylla/Sync/Rooms.elm Normal file
View File

@@ -0,0 +1,109 @@
module Scylla.Sync.Rooms exposing (..)
import Scylla.Sync.DecodeTools exposing (maybeDecode)
import Scylla.Sync.Events exposing (Event, RoomEvent, StateEvent, StrippedStateEvent, stateEventDecoder, strippedStateEventDecoder, roomEventDecoder, eventDecoder)
import Scylla.Sync.AccountData exposing (AccountData, accountDataDecoder)
import Json.Decode as Decode exposing (Decoder, int, string, dict, list, bool)
import Json.Decode.Pipeline exposing (required)
import Dict exposing (Dict)
type alias Rooms =
{ join : Maybe (Dict String JoinedRoom)
, invite : Maybe (Dict String InvitedRoom)
, leave : Maybe (Dict String LeftRoom)
}
roomsDecoder : Decoder Rooms
roomsDecoder =
Decode.succeed Rooms
|> maybeDecode "join" (dict joinedRoomDecoder)
|> maybeDecode "invite" (dict invitedRoomDecoder)
|> maybeDecode "leave" (dict leftRoomDecoder)
type alias JoinedRoom =
{ state : Maybe State
, timeline : Maybe Timeline
, ephemeral : Maybe Ephemeral
, accountData : Maybe AccountData
, unreadNotifications : Maybe UnreadNotificationCounts
}
joinedRoomDecoder : Decoder JoinedRoom
joinedRoomDecoder =
Decode.succeed JoinedRoom
|> maybeDecode "state" stateDecoder
|> maybeDecode "timeline" timelineDecoder
|> maybeDecode "ephemeral" ephemeralDecoder
|> maybeDecode "account_data" accountDataDecoder
|> maybeDecode "unread_notifications" unreadNotificationCountsDecoder
type alias InvitedRoom =
{ inviteState : Maybe InviteState
}
invitedRoomDecoder : Decoder InvitedRoom
invitedRoomDecoder =
Decode.succeed InvitedRoom
|> maybeDecode "invite_state" inviteStateDecoder
type alias LeftRoom =
{ state : Maybe State
, timeline : Maybe Timeline
, accountData : Maybe AccountData
}
leftRoomDecoder : Decoder LeftRoom
leftRoomDecoder =
Decode.succeed LeftRoom
|> maybeDecode "state" stateDecoder
|> maybeDecode "timeline" timelineDecoder
|> maybeDecode "account_data" accountDataDecoder
type alias State =
{ events : Maybe (List StateEvent)
}
stateDecoder : Decoder State
stateDecoder =
Decode.succeed State
|> maybeDecode "events" (list stateEventDecoder)
type alias InviteState =
{ events : Maybe (List StrippedStateEvent)
}
inviteStateDecoder : Decoder InviteState
inviteStateDecoder =
Decode.succeed InviteState
|> maybeDecode "events" (list strippedStateEventDecoder)
type alias Timeline =
{ events : Maybe (List RoomEvent)
, limited : Maybe Bool
, prevBatch : Maybe String
}
timelineDecoder =
Decode.succeed Timeline
|> maybeDecode "events" (list roomEventDecoder)
|> maybeDecode "limited" bool
|> maybeDecode "prev_batch" string
type alias Ephemeral =
{ events : Maybe (List Event)
}
ephemeralDecoder : Decoder Ephemeral
ephemeralDecoder =
Decode.succeed Ephemeral
|> maybeDecode "events" (list eventDecoder)
type alias UnreadNotificationCounts =
{ highlightCount : Maybe Int
, notificationCount : Maybe Int
}
unreadNotificationCountsDecoder : Decoder UnreadNotificationCounts
unreadNotificationCountsDecoder =
Decode.succeed UnreadNotificationCounts
|> maybeDecode "highlight_count" int
|> maybeDecode "notification_count" int

26
src/Scylla/UserData.elm Normal file
View File

@@ -0,0 +1,26 @@
module Scylla.UserData exposing (..)
import Scylla.Login exposing (Username)
import Json.Decode as Decode exposing (Decoder, int, string, float, list, value, dict, bool, field)
import Json.Decode.Pipeline exposing (required, optional)
import Dict exposing (Dict)
type alias UserData =
{ displayName : Maybe String
, avatarUrl : Maybe String
}
userDataDecoder : Decoder UserData
userDataDecoder =
Decode.succeed UserData
|> optional "displayname" (Decode.map Just string) Nothing
|> optional "avatar_url" (Decode.map Just string) Nothing
getSenderName : Username -> String
getSenderName s =
let
colonIndex = Maybe.withDefault -1
<| List.head
<| String.indexes ":" s
in
String.slice 1 colonIndex s

View File

@@ -1,16 +1,41 @@
module Scylla.Views exposing (..)
import Scylla.Model exposing (..)
import Scylla.Sync exposing (..)
import Scylla.Sync.Events exposing (..)
import Scylla.Sync.Rooms exposing (..)
import Scylla.Room exposing (RoomData, emptyOpenRooms, getHomeserver, getRoomName, getRoomTypingUsers, getLocalDisplayName)
import Scylla.Route exposing (..)
import Scylla.Fnv as Fnv
import Scylla.Messages exposing (..)
import Scylla.Login exposing (Username)
import Scylla.Http exposing (fullMediaUrl)
import Scylla.Api exposing (ApiUrl)
import Scylla.ListUtils exposing (groupBy)
import Html.Parser
import Html.Parser.Util
import Svg
import Svg.Attributes
import Url.Builder
import Json.Decode as Decode
import Html exposing (Html, div, input, text, button, div, span, a, h2, table, td, tr)
import Html.Attributes exposing (type_, value, href, class, style)
import Html.Events exposing (onInput, onClick)
import Dict
import Html exposing (Html, Attribute, div, input, text, button, div, span, a, h2, h3, table, td, tr, img, textarea, video, source, p)
import Html.Attributes exposing (type_, placeholder, value, href, class, style, src, id, rows, controls, src, classList)
import Html.Events exposing (onInput, onClick, preventDefaultOn)
import Html.Lazy exposing (lazy5)
import Dict exposing (Dict)
import Tuple
maybeHtml : List (Maybe (Html Msg)) -> List (Html Msg)
maybeHtml = List.filterMap (\i -> i)
contentRepositoryDownloadUrl : ApiUrl -> String -> String
contentRepositoryDownloadUrl apiUrl s =
let
lastIndex = Maybe.withDefault 6 <| List.head <| List.reverse <| String.indexes "/" s
authority = String.slice 6 lastIndex s
content = String.dropLeft (lastIndex + 1) s
in
(fullMediaUrl apiUrl) ++ "/download/" ++ authority ++ "/" ++ content
stringColor : String -> String
stringColor s =
@@ -19,21 +44,11 @@ stringColor s =
in
"hsl(" ++ hue ++ ", 82%, 71%)"
senderName : String -> String
senderName s =
let
colonIndex = Maybe.withDefault -1
<| List.head
<| String.indexes ":" s
in
String.slice 1 colonIndex s
viewFull : Model -> List (Html Msg)
viewFull model =
let
room r = Maybe.map (\jr -> (r, jr))
<| Maybe.andThen (Dict.get r)
<| Maybe.andThen .join model.sync.rooms
room r = Dict.get r model.rooms
|> Maybe.map (\rd -> (r, rd))
core = case model.route of
Login -> loginView model
Base -> baseView model Nothing
@@ -44,71 +59,155 @@ viewFull model =
[ errorList ] ++ [ core ]
errorsView : List String -> Html Msg
errorsView = div [] << List.map errorView
errorsView = div [ class "errors-wrapper" ] << List.indexedMap errorView
errorView : String -> Html Msg
errorView s = div [] [ text s ]
errorView : Int -> String -> Html Msg
errorView i s = div [ class "error-wrapper", onClick <| DismissError i ] [ iconView "alert-triangle", text s ]
baseView : Model -> Maybe (String, JoinedRoom) -> Html Msg
baseView m jr =
baseView : Model -> Maybe (RoomId, RoomData) -> Html Msg
baseView m rd =
let
roomView = case jr of
Just (id, r) -> joinedRoomView m id r
Nothing -> div [] []
roomView = Maybe.map (\(id, r) -> joinedRoomView m id r) rd
reconnect = reconnectView m
in
div [ class "base-wrapper" ]
[ roomListView m
div [ class "base-wrapper" ] <| maybeHtml
[ Just <| roomListView m
, roomView
, reconnect
]
reconnectView : Model -> Maybe (Html Msg)
reconnectView m = if m.connected
then Nothing
else Just <| div [ class "reconnect-wrapper", onClick AttemptReconnect ] [ iconView "zap", text "Disconnected. Click here to reconnect." ]
roomListView : Model -> Html Msg
roomListView m =
let
rooms = Maybe.withDefault (Dict.empty) <| Maybe.andThen .join <| m.sync.rooms
roomList = div [ class "rooms-list" ] <| Dict.values <| Dict.map roomListElementView rooms
groups = roomGroups
<| Dict.toList m.rooms
homeserverList = div [ class "homeservers-list" ]
<| List.map (\(k, v) -> homeserverView m k v)
<| Dict.toList groups
in
div [ class "rooms-wrapper" ]
[ h2 [] [ text "Rooms" ]
, roomList
, input
[ class "room-search"
, type_ "text"
, placeholder "Search chats..."
, onInput UpdateSearchText
, value m.searchText
] []
, homeserverList
]
roomListElementView : String -> JoinedRoom -> Html Msg
roomListElementView s jr =
roomGroups : List (String, RoomData) -> Dict String (List (String, RoomData))
roomGroups jrs = groupBy (getHomeserver << Tuple.first) jrs
homeserverView : Model -> String -> List (String, RoomData) -> Html Msg
homeserverView m hs rs =
let
name = Maybe.withDefault "<No Name>" <| roomName jr
roomList = div [ class "rooms-list" ]
<| List.map (\(rid, r) -> roomListElementView m rid r)
<| List.sortBy (\(rid, r) -> getRoomName m.accountData rid r) rs
in
a [ href <| Url.Builder.absolute [ "room", s ] [] ] [ text name ]
div [ class "homeserver-wrapper" ] [ h3 [] [ text hs ], roomList ]
roomListElementView : Model -> RoomId -> RoomData -> Html Msg
roomListElementView m rid rd =
let
name = getRoomName m.accountData rid rd
isVisible = m.searchText == "" || (String.contains (String.toLower m.searchText) <| String.toLower name)
isCurrentRoom = case currentRoomId m of
Nothing -> False
Just cr -> cr == rid
in
div [ classList
[ ("room-link-wrapper", True)
, ("active", isCurrentRoom)
, ("hidden", not isVisible)
]
]
<| roomNotificationCountView rd.unreadNotifications ++
[ a [ href <| roomUrl rid ] [ text name ] ]
roomNotificationCountView : UnreadNotificationCounts -> List (Html Msg)
roomNotificationCountView ns =
let
wrap b = span
[ classList
[ ("notification-count", True)
, ("bright", b)
]
]
getCount f = Maybe.withDefault 0 << f
in
case (getCount .notificationCount ns, getCount .highlightCount ns) of
(0, 0) -> []
(i, 0) -> [ wrap False [ iconView "bell", text <| String.fromInt i ] ]
(i, j) -> [ wrap True [ iconView "alert-circle", text <| String.fromInt i ] ]
loginView : Model -> Html Msg
loginView m = div [ class "login-wrapper" ]
[ h2 [] [ text "Log In" ]
, input [ type_ "text", value m.loginUsername, onInput ChangeLoginUsername] []
, input [ type_ "password", value m.loginPassword, onInput ChangeLoginPassword ] []
, input [ type_ "text", value m.apiUrl, onInput ChangeApiUrl ] []
, input [ type_ "text", placeholder "Username", value m.loginUsername, onInput ChangeLoginUsername] []
, input [ type_ "password", placeholder "Password", value m.loginPassword, onInput ChangeLoginPassword ] []
, input [ type_ "text", placeholder "Homeserver URL", value m.apiUrl, onInput ChangeApiUrl ] []
, button [ onClick AttemptLogin ] [ text "Log In" ]
]
joinedRoomView : Model -> String -> JoinedRoom -> Html Msg
joinedRoomView m roomId jr =
joinedRoomView : Model -> RoomId -> RoomData -> Html Msg
joinedRoomView m roomId rd =
let
events = Maybe.withDefault [] <| Maybe.andThen .events jr.timeline
renderedEvents = List.filterMap (eventView m) events
eventWrapper = eventWrapperView m renderedEvents
typing = List.map (getLocalDisplayName rd) <| getRoomTypingUsers rd
typingText = String.join ", " typing
typingSuffix = case List.length typing of
0 -> ""
1 -> " is typing..."
_ -> " are typing..."
typingWrapper = div [ class "typing-wrapper" ] [ text <| typingText ++ typingSuffix ]
messageInput = div [ class "message-wrapper" ]
[ input
[ type_ "text"
[ textarea
[ rows 1
, onInput <| ChangeRoomText roomId
, onEnterKey <| SendRoomText roomId
, placeholder "Type your message here..."
, value <| Maybe.withDefault "" <| Dict.get roomId m.roomText
] []
, button [ onClick <| SendFiles roomId ] [ iconView "file" ]
, button [ onClick <| SendImages roomId ] [ iconView "image" ]
, button [ onClick <| SendRoomText roomId ] [ iconView "send" ]
]
in
div [ class "room-wrapper" ]
[ h2 [] [ text <| Maybe.withDefault "<No Name>" <| roomName jr ]
, eventWrapper
[ h2 [] [ text <| getRoomName m.accountData roomId rd ]
, lazy5 lazyMessagesView roomId rd m.apiUrl m.loginUsername m.sending
, messageInput
, typingWrapper
]
lazyMessagesView : RoomId -> RoomData -> ApiUrl -> Username -> Dict Int (RoomId, SendingMessage) -> Html Msg
lazyMessagesView rid rd au lu snd =
let
roomReceived = getReceivedMessages rd
roomSending = getSendingMessages rid snd
renderedMessages = List.map (userMessagesView rd au)
<| groupMessages lu
<| roomReceived ++ roomSending
in
messagesWrapperView rid renderedMessages
onEnterKey : Msg -> Attribute Msg
onEnterKey msg =
let
eventDecoder = Decode.map2 (\l r -> (l, r)) (Decode.field "keyCode" Decode.int) (Decode.field "shiftKey" Decode.bool)
msgFor (code, shift) = if code == 13 && not shift then Decode.succeed msg else Decode.fail "Not ENTER"
pairTrue v = (v, True)
decoder = Decode.map pairTrue <| Decode.andThen msgFor <| eventDecoder
in
preventDefaultOn "keydown" decoder
iconView : String -> Html Msg
iconView name =
let
@@ -118,40 +217,105 @@ iconView name =
[ Svg.Attributes.class "feather-icon"
] [ Svg.use [ Svg.Attributes.xlinkHref (url ++ "#" ++ name) ] [] ]
eventWrapperView : Model -> List (Html Msg) -> Html Msg
eventWrapperView m es = div [ class "events-wrapper" ] [ table [ class "events-table" ] es ]
eventView : Model -> RoomEvent -> Maybe (Html Msg)
eventView m re =
let
viewFunction = case re.type_ of
"m.room.message" -> Just messageView
_ -> Nothing
createRow mhtml = tr []
[ td [] [ eventSenderView re.sender ]
, td [] [ mhtml ]
messagesWrapperView : RoomId -> List (Html Msg) -> Html Msg
messagesWrapperView rid es = div [ class "messages-wrapper", id "messages-wrapper" ]
[ a [ class "history-link", onClick <| History rid ] [ text "Load older messages" ]
, table [ class "messages-table" ] es
]
senderView : RoomData -> Username -> Html Msg
senderView rd s =
span [ style "color" <| stringColor s, class "sender-wrapper" ] [ text <| getLocalDisplayName rd s ]
userMessagesView : RoomData -> ApiUrl -> (Username, List Message) -> Html Msg
userMessagesView rd apiUrl (u, ms) =
let
wrap h = div [ class "message" ] [ h ]
in
Maybe.map createRow
<| Maybe.andThen (\f -> f m re) viewFunction
tr []
[ td [] [ senderView rd u ]
, td [] <| List.map wrap <| List.filterMap (messageView rd apiUrl) ms
]
eventSenderView : String -> Html Msg
eventSenderView s =
span [ style "background-color" <| stringColor s, class "sender-wrapper" ] [ text <| senderName s ]
messageView : RoomData -> ApiUrl -> Message -> Maybe (Html Msg)
messageView rd apiUrl msg = case msg of
Sending t -> Just <| sendingMessageView t
Received re -> roomEventView rd apiUrl re
messageView : Model -> RoomEvent -> Maybe (Html Msg)
messageView m re =
sendingMessageView : SendingMessage -> Html Msg
sendingMessageView msg = case msg.body of
TextMessage t -> span [ class "sending"] [ text t ]
roomEventView : RoomData -> ApiUrl -> MessageEvent -> Maybe (Html Msg)
roomEventView rd apiUrl re =
let
msgtype = Decode.decodeValue (Decode.field "msgtype" Decode.string) re.content
in
case msgtype of
Ok "m.text" -> messageTextView m re
Ok "m.text" -> roomEventTextView re
Ok "m.notice" -> roomEventNoticeView re
Ok "m.emote" -> roomEventEmoteView rd re
Ok "m.image" -> roomEventImageView apiUrl re
Ok "m.file" -> roomEventFileView apiUrl re
Ok "m.video" -> roomEventVideoView apiUrl re
_ -> Nothing
messageTextView : Model -> RoomEvent -> Maybe (Html Msg)
messageTextView m re =
roomEventFormattedContent : MessageEvent -> Maybe (List (Html Msg))
roomEventFormattedContent re = Maybe.map Html.Parser.Util.toVirtualDom
<| Maybe.andThen (Result.toMaybe << Html.Parser.run )
<| Result.toMaybe
<| Decode.decodeValue (Decode.field "formatted_body" Decode.string) re.content
roomEventContent : (List (Html Msg) -> Html Msg) -> MessageEvent -> Maybe (Html Msg)
roomEventContent f re =
let
body = Decode.decodeValue (Decode.field "body" Decode.string) re.content
wrap mtext = span [] [ text mtext ]
customHtml = roomEventFormattedContent re
in
Maybe.map wrap <| Result.toMaybe body
case customHtml of
Just c -> Just <| f c
Nothing -> Maybe.map (f << List.singleton << text) <| Result.toMaybe body
roomEventEmoteView : RoomData -> MessageEvent -> Maybe (Html Msg)
roomEventEmoteView rd re =
let
emoteText = "* " ++ getLocalDisplayName rd re.sender ++ " "
in
roomEventContent (\cs -> span [] (text emoteText :: cs)) re
roomEventNoticeView : MessageEvent -> Maybe (Html Msg)
roomEventNoticeView = roomEventContent (span [ class "message-notice" ])
roomEventTextView : MessageEvent -> Maybe (Html Msg)
roomEventTextView = roomEventContent (span [])
roomEventImageView : ApiUrl -> MessageEvent -> Maybe (Html Msg)
roomEventImageView apiUrl re =
let
body = Decode.decodeValue (Decode.field "url" Decode.string) re.content
in
Maybe.map (\s -> img [ class "message-image", src s ] [])
<| Maybe.map (contentRepositoryDownloadUrl apiUrl)
<| Result.toMaybe body
roomEventFileView : ApiUrl -> MessageEvent -> Maybe (Html Msg)
roomEventFileView apiUrl re =
let
decoder = Decode.map2 (\l r -> (l, r)) (Decode.field "url" Decode.string) (Decode.field "body" Decode.string)
fileData = Decode.decodeValue decoder re.content
in
Maybe.map (\(url, name) -> a [ href url, class "file-wrapper" ] [ iconView "file", text name ])
<| Maybe.map (\(url, name) -> (contentRepositoryDownloadUrl apiUrl url, name))
<| Result.toMaybe fileData
roomEventVideoView : ApiUrl -> MessageEvent -> Maybe (Html Msg)
roomEventVideoView apiUrl re =
let
decoder = Decode.map2 (\l r -> (l, r))
(Decode.field "url" Decode.string)
(Decode.field "info" <| Decode.field "mimetype" Decode.string)
videoData = Decode.decodeValue decoder re.content
in
Maybe.map (\(url, t) -> video [ controls True ] [ source [ src url, type_ t ] [] ])
<| Maybe.map (\(url, type_) -> (contentRepositoryDownloadUrl apiUrl url, type_))
<| Result.toMaybe videoData

9
static/js/markdown.js Normal file
View File

@@ -0,0 +1,9 @@
function setupMarkdownPorts(app) {
app.ports.requestMarkdownPort.subscribe(function(data) {
app.ports.receiveMarkdownPort.send({
"roomId" : data.roomId,
"text" : data.text,
"markdown" : marked(data.text)
});
})
}

View File

@@ -0,0 +1,16 @@
if("Notification" in window) {
Notification.requestPermission();
}
function setupNotificationPorts(app) {
app.ports.sendNotificationPort.subscribe(function(data) {
var options = {
"body" : data.text
}
var n = new Notification(data.name, options)
n.onclick = function() {
app.ports.onNotificationClickPort.send(data.room);
n.close();
}
})
}

10
static/js/storage.js Normal file
View File

@@ -0,0 +1,10 @@
function setupStorage(app) {
app.ports.setStoreValuePort.subscribe(function(data) {
key = data[0];
value = data[1];
localStorage.setItem(key, value);
});
app.ports.getStoreValuePort.subscribe(function(data) {
app.ports.receiveStoreValuePort.send({ "key" : data, "value" : localStorage.getItem(data) });
});
}

View File

@@ -0,0 +1,3 @@
@import 'mixins';
@import 'variables';

12
static/scss/mixins.scss Normal file
View File

@@ -0,0 +1,12 @@
@mixin input-common {
padding: 5px;
border-radius: $border-radius;
outline: none;
font-family: inherit;
font-size: inherit;
&:focus {
transition: background-color $transition-duration;
}
}

View File

@@ -1,13 +1,7 @@
@import url('https://fonts.googleapis.com/css?family=Open+Sans');
$primary-color: #53C0FA;
$primary-color-highlight: #4298C7;
$primary-color-light: #9FDBFB;
$active-input-color: white;
$transition-duration: .125s;
$inactive-input-color: darken($active-input-color, 3%);
$active-input-border-color: $primary-color;
$inactive-input-border-color: darken($inactive-input-color, 10%);
@import url('https://fonts.googleapis.com/css?family=Open+Sans|Source+Code+Pro');
@import 'mixins';
@import 'variables';
@import 'components';
html, body {
height: 100vh;
@@ -15,27 +9,22 @@ html, body {
body {
font-family: 'Open Sans', sans-serif;
margin: 0px;
background-color: #fbfbfb;
margin: 0;
background-color: $background-color;
font-size: .7em;
color: white;
}
@mixin input-common {
padding: 5px;
border-radius: 3px;
outline: none;
transition: background-color $transition-duration;
}
input {
input, textarea {
@include input-common();
overflow-x: hidden;
background-color: $inactive-input-color;
color: black;
border: .5px solid $inactive-input-border-color;
color: white;
border: none;
padding: .5em;
&:focus {
background-color: $active-input-color;
border-color: $active-input-border-color;
}
}
@@ -51,6 +40,7 @@ button {
}
}
a {
text-decoration: none;
color: $primary-color;
@@ -60,9 +50,44 @@ a {
}
}
h2 {
margin: 0px;
margin-bottom: 3px;
h1, h2, h3, h4, h5, h6 {
margin: 0;
margin-bottom: .5em;
}
/*
* Errors
*/
div.errors-wrapper {
position: fixed;
pointer-events: none;
top: 0;
bottom: 0;
left: 0;
right: 0;
overflow: hidden;
}
div.error-wrapper {
pointer-events: auto;
width: 40%;
padding: .5em;
background-color: $error-color;
border: .1em solid $error-color-dark;
color: white;
margin: auto;
margin-top: .85em;
margin-bottom: .85em;
font-size: 1em;
align-items: center;
overflow-x: hidden;
white-space: nowrap;
text-overflow: ellipsis;
.feather-icon {
margin-right: .85em;
}
}
/*
@@ -71,12 +96,12 @@ h2 {
div.login-wrapper {
display: flex;
flex-direction: column;
max-width: 300px;
max-width: 30%;
margin: auto;
margin-top: 20px;
margin-top: 1.5em;
input, button {
margin: 3px;
margin: .3em;
}
}
@@ -86,27 +111,86 @@ div.login-wrapper {
div.base-wrapper {
display: flex;
height: 100%;
div {
padding: 3px;
box-sizing: border-box;
}
h2 {
padding-bottom: 5px;
margin-bottom: 5px;
border-bottom: 1px solid $inactive-input-border-color;
}
}
/*
* The list of rooms
*/
div.rooms-container {
border-right: .1em solid $background-color-dark;
}
div.rooms-wrapper {
flex-shrink: 0;
width: 15%;
min-width: 20em;
padding: .85em;
box-sizing: border-box;
border-right: .1em solid $background-color-dark;
.room-search {
padding: .5em;
width: 100%;
box-sizing: border-box;
}
}
div.room-link-wrapper {
whitespace: nowrap;
border-left: solid .2em $background-color;
padding-left: .5em;
margin: .3em;
display: flex;
align-items: center;
.feather-icon {
height: 1.2em;
}
span.notification-count {
color: $alert-color;
margin-right: .5em;
display: flex;
align-items: center;
&.bright {
color: $alert-color-bright;
}
}
a {
display: block;
color: lightgrey;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
&:hover a {
color: $primary-color;
transition: color $transition-duration;
}
&.active {
border-left: solid .2em $primary-color;
}
&.hidden {
display: none;
}
}
div.reconnect-wrapper {
position: fixed;
bottom: 1.5em;
left: 1.5em;
padding: .85em;
display: flex;
align-items: center;
background-color: $inactive-input-color;
border-radius: $border-radius;
.feather-icon {
margin-right: .85em;
}
}
@@ -114,57 +198,156 @@ div.rooms-wrapper {
* The current room, if any.
*/
div.room-wrapper {
flex-grow: 1;
display: flex;
height: 100%;
flex-direction: column;
padding: .85em;
}
div.typing-wrapper {
padding: .5em;
height: 1em;
flex-shrink: 0;
}
/*
* The message input and send button.
*/
div.message-wrapper {
display: flex;
flex-shrink: 0;
input {
flex-grow: 9;
margin: 3px;
input, textarea {
flex-grow: 12;
margin: .3em;
}
button {
flex-grow: 1;
margin: 3px;
margin: .3em;
height: 3em;
width: 3em;
transition: color $transition-duration;
background-color: $background-color;
color: $primary-color;
&:hover {
color: $primary-color-light;
}
}
}
div.events-wrapper {
div.messages-wrapper {
overflow-y: scroll;
flex-grow: 1;
a.history-link {
display: block;
width: 100%;
text-align: center;
box-sizing: border-box;
padding: .5em;
}
}
table.events-table {
table.messages-table {
border-collapse: collapse;
width: 100%;
table-layout: fixed;
td {
padding-left: 5px;
padding: .5em;
vertical-align: top;
}
img {
max-width: 90%;
max-height: 30em;
margin-top: .85em;
margin-bottom: .85em;
box-shadow: 0 0 .5em rgba(0, 0, 0, .5);
}
.sending {
color: grey;
}
video {
max-width: 90%;
max-height: 30em;
}
td:nth-child(1) {
width: 10%;
white-space: nowrap;
}
}
div.message {
p {
margin: 0;
}
blockquote {
margin: 0 0 0 0;
padding-left: .5em;
border-left: .4em solid $primary-color;
}
code {
font-family: Iosevka, "Source Code Pro", monospace,
}
pre code {
width: 100%;
overflow: auto;
display: block;
box-sizing: border-box;
padding: .85em;
background-color: $background-color-dark;
border-radius: $border-radius;
box-shadow: $inset-shadow;
}
}
span.sender-wrapper {
border-radius: 2px;
padding-left: 5px;
padding-right: 5px;
float: right;
border-radius: $border-radius;
padding-left: .5em;
padding-right: .5em;
display: inline-block;
box-sizing: border-box;
text-align: right;
font-weight: 800;
width: 100%;
text-overflow: ellipsis;
overflow: hidden;
vertical-align: bottom; /* Fix for overflow changing element height */
color: black;
}
a.file-wrapper {
padding: .5em 0 .5em 0;
display: flex;
align-items: center;
.feather-icon {
height: 2em;
width: 2em;
margin-right: .85em;
}
}
.message-notice {
text-transform: uppercase;
}
/**
* Icons
*/
.feather-icon {
vertical-align: middle;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
fill: none;
height: 20px;
width: 20px;
height: 1.5em;
width: 1.5em;
}

View File

@@ -0,0 +1,26 @@
// Colors
$primary-color: #53C0FA;
$primary-color-highlight: #4298C7;
$primary-color-light: #9FDBFB;
$background-color: #1b1e21;
$background-color-light: lighten($background-color, 4%);
$background-color-dark: darken($background-color, 4%);
$error-color: #f01d43;
$error-color-dark: darken(#f01d43, 10%);
$alert-color: #18f49c;
$alert-color-bright: rgb(240, 244, 24);
$inactive-input-color: lighten($background-color-light, 5%);
$active-input-color: lighten($inactive-input-color, 5%);
// Transitions
$transition-duration: .250s;
// Shadows
$inset-shadow: inset 0px 0px 5px rgba(0, 0, 0, .25);
// Borders
$border-radius: 3px;