Compare commits
7 Commits
490d0eff2c
...
6ea55241c8
Author | SHA1 | Date | |
---|---|---|---|
6ea55241c8 | |||
12e5fdfbf1 | |||
525a6dd878 | |||
859023942e | |||
2d133167ed | |||
c08ef14832 | |||
50701e1885 |
24
README.md
Normal file
24
README.md
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
# Scylla
|
||||||
|
A minimalist client for the Matrix chat protocol.
|
||||||
|
|
||||||
|
## 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.
|
21
index.html
Normal file
21
index.html
Normal 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
screenshots/screenshot-1.png
Normal file
BIN
screenshots/screenshot-1.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 111 KiB |
BIN
screenshots/screenshot-2.png
Normal file
BIN
screenshots/screenshot-2.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
BIN
screenshots/screenshot-3.png
Normal file
BIN
screenshots/screenshot-3.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 20 KiB |
19
src/Main.elm
19
src/Main.elm
|
@ -12,6 +12,7 @@ import Scylla.UserData exposing (..)
|
||||||
import Scylla.Notification exposing (..)
|
import Scylla.Notification exposing (..)
|
||||||
import Scylla.Storage exposing (..)
|
import Scylla.Storage exposing (..)
|
||||||
import Scylla.Markdown exposing (..)
|
import Scylla.Markdown exposing (..)
|
||||||
|
import Scylla.AccountData exposing (..)
|
||||||
import Url exposing (Url)
|
import Url exposing (Url)
|
||||||
import Url.Parser exposing (parse)
|
import Url.Parser exposing (parse)
|
||||||
import Url.Builder
|
import Url.Builder
|
||||||
|
@ -92,6 +93,10 @@ update msg model = case msg of
|
||||||
SendImageResponse _ -> (model, Cmd.none)
|
SendImageResponse _ -> (model, Cmd.none)
|
||||||
SendFileResponse _ -> (model, Cmd.none)
|
SendFileResponse _ -> (model, Cmd.none)
|
||||||
ReceiveMarkdown md -> updateMarkdown model md
|
ReceiveMarkdown md -> updateMarkdown model md
|
||||||
|
DismissError i -> updateDismissError model i
|
||||||
|
|
||||||
|
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 : Model -> MarkdownResponse -> (Model, Cmd Msg)
|
||||||
updateMarkdown m { roomId, text, markdown } =
|
updateMarkdown m { roomId, text, markdown } =
|
||||||
|
@ -109,8 +114,11 @@ updateFileUploadComplete m rid mime ur =
|
||||||
command = case ur of
|
command = case ur of
|
||||||
Ok u -> sendFileMessage m.apiUrl (Maybe.withDefault "" m.token) m.transactionId rid mime u
|
Ok u -> sendFileMessage m.apiUrl (Maybe.withDefault "" m.token) m.transactionId rid mime u
|
||||||
_ -> Cmd.none
|
_ -> Cmd.none
|
||||||
|
newErrors = case ur of
|
||||||
|
Err e -> [ "Error uploading file. Please check your internet connection and try again." ]
|
||||||
|
_ -> []
|
||||||
in
|
in
|
||||||
({ m | transactionId = m.transactionId + 1}, command)
|
({ m | errors = newErrors ++ m.errors, transactionId = m.transactionId + 1}, command)
|
||||||
|
|
||||||
updateImageUploadComplete : Model -> RoomId -> String -> (Result Http.Error String) -> (Model, Cmd Msg)
|
updateImageUploadComplete : Model -> RoomId -> String -> (Result Http.Error String) -> (Model, Cmd Msg)
|
||||||
updateImageUploadComplete m rid mime ur =
|
updateImageUploadComplete m rid mime ur =
|
||||||
|
@ -118,6 +126,9 @@ updateImageUploadComplete m rid mime ur =
|
||||||
command = case ur of
|
command = case ur of
|
||||||
Ok u -> sendImageMessage m.apiUrl (Maybe.withDefault "" m.token) m.transactionId rid mime u
|
Ok u -> sendImageMessage m.apiUrl (Maybe.withDefault "" m.token) m.transactionId rid mime u
|
||||||
_ -> Cmd.none
|
_ -> Cmd.none
|
||||||
|
newErrors = case ur of
|
||||||
|
Err e -> [ "Error uploading image. Please check your internet connection and try again." ]
|
||||||
|
_ -> []
|
||||||
in
|
in
|
||||||
({ m | transactionId = m.transactionId + 1}, command)
|
({ m | transactionId = m.transactionId + 1}, command)
|
||||||
|
|
||||||
|
@ -140,7 +151,7 @@ updateHistoryResponse m r hr =
|
||||||
in
|
in
|
||||||
case hr of
|
case hr of
|
||||||
Ok h -> ({ m | sync = appendHistoryResponse m.sync r h }, newUsersCmd h)
|
Ok h -> ({ m | sync = appendHistoryResponse m.sync r h }, newUsersCmd h)
|
||||||
Err _ -> (m, Cmd.none)
|
Err _ -> ({ m | errors = "Unable to load older history from server"::m.errors }, Cmd.none)
|
||||||
|
|
||||||
updateHistory : Model -> RoomId -> (Model, Cmd Msg)
|
updateHistory : Model -> RoomId -> (Model, Cmd Msg)
|
||||||
updateHistory m r =
|
updateHistory m r =
|
||||||
|
@ -225,7 +236,7 @@ updateViewportAfterMessage m vr =
|
||||||
updateUserData : Model -> String -> Result Http.Error UserData -> (Model, Cmd Msg)
|
updateUserData : Model -> String -> Result Http.Error UserData -> (Model, Cmd Msg)
|
||||||
updateUserData m s r = case r of
|
updateUserData m s r = case r of
|
||||||
Ok ud -> ({ m | userData = Dict.insert s ud m.userData }, Cmd.none)
|
Ok ud -> ({ m | userData = Dict.insert s ud m.userData }, Cmd.none)
|
||||||
Err e -> (m, userData m.apiUrl (Maybe.withDefault "" m.token) s)
|
Err e -> ({ m | errors = ("Failed to retrieve user data for user " ++ s)::m.errors }, userData m.apiUrl (Maybe.withDefault "" m.token) s)
|
||||||
|
|
||||||
updateSendRoomText : Model -> RoomId -> (Model, Cmd Msg)
|
updateSendRoomText : Model -> RoomId -> (Model, Cmd Msg)
|
||||||
updateSendRoomText m r =
|
updateSendRoomText m r =
|
||||||
|
@ -256,7 +267,7 @@ updateLoginResponse model a r = case r of
|
||||||
<| encodeLoginInfo
|
<| encodeLoginInfo
|
||||||
<| LoginInfo lr.accessToken model.apiUrl lr.userId model.transactionId)
|
<| 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 -> Bool -> (Model, Cmd Msg)
|
updateSyncResponse : Model -> Result Http.Error SyncResponse -> Bool -> (Model, Cmd Msg)
|
||||||
updateSyncResponse model r notify =
|
updateSyncResponse model r notify =
|
||||||
|
|
22
src/Scylla/AccountData.elm
Normal file
22
src/Scylla/AccountData.elm
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
module Scylla.AccountData exposing (..)
|
||||||
|
import Scylla.Sync exposing (AccountData, JoinedRoom, roomAccountData)
|
||||||
|
import Json.Decode as Decode
|
||||||
|
|
||||||
|
type NotificationSetting = Normal | MentionsOnly | None
|
||||||
|
|
||||||
|
notificationSettingDecoder : Decode.Decoder NotificationSetting
|
||||||
|
notificationSettingDecoder =
|
||||||
|
let
|
||||||
|
checkString s = case s of
|
||||||
|
"Normal" -> Decode.succeed Normal
|
||||||
|
"MentionsOnly" -> Decode.succeed MentionsOnly
|
||||||
|
"None" -> Decode.succeed None
|
||||||
|
_ -> Decode.fail "Not a valid notification setting"
|
||||||
|
in
|
||||||
|
Decode.andThen checkString Decode.string
|
||||||
|
|
||||||
|
roomNotificationSetting : JoinedRoom -> NotificationSetting
|
||||||
|
roomNotificationSetting jr = Maybe.withDefault Normal
|
||||||
|
<| Maybe.andThen Result.toMaybe
|
||||||
|
<| Maybe.map (Decode.decodeValue notificationSettingDecoder)
|
||||||
|
<| roomAccountData jr "com.danilafe.scylla.notifications"
|
|
@ -63,6 +63,7 @@ type Msg =
|
||||||
| SendImageResponse (Result Http.Error ())
|
| SendImageResponse (Result Http.Error ())
|
||||||
| SendFileResponse (Result Http.Error ())
|
| SendFileResponse (Result Http.Error ())
|
||||||
| ReceiveMarkdown MarkdownResponse
|
| ReceiveMarkdown MarkdownResponse
|
||||||
|
| DismissError Int
|
||||||
|
|
||||||
displayName : Model -> Username -> String
|
displayName : Model -> Username -> String
|
||||||
displayName m s = Maybe.withDefault (senderName s) <| Maybe.andThen .displayName <| Dict.get s m.userData
|
displayName m s = Maybe.withDefault (senderName s) <| Maybe.andThen .displayName <| Dict.get s m.userData
|
||||||
|
|
|
@ -12,8 +12,8 @@ import Svg
|
||||||
import Svg.Attributes
|
import Svg.Attributes
|
||||||
import Url.Builder
|
import Url.Builder
|
||||||
import Json.Decode as Decode
|
import Json.Decode as Decode
|
||||||
import Html exposing (Html, Attribute, div, input, text, button, div, span, a, h2, table, td, tr, img, textarea)
|
import Html exposing (Html, Attribute, div, input, text, button, div, span, a, h2, table, td, tr, img, textarea, video, source)
|
||||||
import Html.Attributes exposing (type_, value, href, class, style, src, id, rows)
|
import Html.Attributes exposing (type_, value, href, class, style, src, id, rows, controls, src)
|
||||||
import Html.Events exposing (onInput, onClick, preventDefaultOn)
|
import Html.Events exposing (onInput, onClick, preventDefaultOn)
|
||||||
import Dict
|
import Dict
|
||||||
|
|
||||||
|
@ -50,10 +50,10 @@ viewFull model =
|
||||||
[ errorList ] ++ [ core ]
|
[ errorList ] ++ [ core ]
|
||||||
|
|
||||||
errorsView : List String -> Html Msg
|
errorsView : List String -> Html Msg
|
||||||
errorsView = div [] << List.map errorView
|
errorsView = div [ class "errors-wrapper" ] << List.indexedMap errorView
|
||||||
|
|
||||||
errorView : String -> Html Msg
|
errorView : Int -> String -> Html Msg
|
||||||
errorView s = div [] [ text s ]
|
errorView i s = div [ class "error-wrapper", onClick <| DismissError i ] [ iconView "alert-triangle", text s ]
|
||||||
|
|
||||||
baseView : Model -> Maybe (String, JoinedRoom) -> Html Msg
|
baseView : Model -> Maybe (String, JoinedRoom) -> Html Msg
|
||||||
baseView m jr =
|
baseView m jr =
|
||||||
|
@ -194,6 +194,8 @@ messageView m re =
|
||||||
case msgtype of
|
case msgtype of
|
||||||
Ok "m.text" -> messageTextView m re
|
Ok "m.text" -> messageTextView m re
|
||||||
Ok "m.image" -> messageImageView m re
|
Ok "m.image" -> messageImageView m re
|
||||||
|
Ok "m.file" -> messageFileView m re
|
||||||
|
Ok "m.video" -> messageVideoView m re
|
||||||
_ -> Nothing
|
_ -> Nothing
|
||||||
|
|
||||||
messageTextView : Model -> RoomEvent -> Maybe (Html Msg)
|
messageTextView : Model -> RoomEvent -> Maybe (Html Msg)
|
||||||
|
@ -218,3 +220,25 @@ messageImageView m re =
|
||||||
Maybe.map (\s -> img [ class "message-image", src s ] [])
|
Maybe.map (\s -> img [ class "message-image", src s ] [])
|
||||||
<| Maybe.map (contentRepositoryDownloadUrl m.apiUrl)
|
<| Maybe.map (contentRepositoryDownloadUrl m.apiUrl)
|
||||||
<| Result.toMaybe body
|
<| Result.toMaybe body
|
||||||
|
|
||||||
|
messageFileView : Model -> RoomEvent -> Maybe (Html Msg)
|
||||||
|
messageFileView m 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 m.apiUrl url, name))
|
||||||
|
<| Result.toMaybe fileData
|
||||||
|
|
||||||
|
messageVideoView : Model -> RoomEvent -> Maybe (Html Msg)
|
||||||
|
messageVideoView m 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 m.apiUrl url, type_))
|
||||||
|
<| Result.toMaybe videoData
|
||||||
|
|
|
@ -5,6 +5,8 @@ $primary-color-light: #9FDBFB;
|
||||||
$active-input-color: white;
|
$active-input-color: white;
|
||||||
$background-color: #fafafa;
|
$background-color: #fafafa;
|
||||||
$background-color-dark: darken($background-color, 4%);
|
$background-color-dark: darken($background-color, 4%);
|
||||||
|
$error-color: #f01d43;
|
||||||
|
$error-color-dark: darken(#f01d43, 10%);
|
||||||
$transition-duration: .125s;
|
$transition-duration: .125s;
|
||||||
|
|
||||||
$inactive-input-color: darken($active-input-color, 3%);
|
$inactive-input-color: darken($active-input-color, 3%);
|
||||||
|
@ -70,6 +72,48 @@ h2 {
|
||||||
margin-bottom: 3px;
|
margin-bottom: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.file-wrapper {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.feather-icon {
|
||||||
|
height: 30px;
|
||||||
|
width: 30px;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
div.errors-wrapper {
|
||||||
|
position: fixed;
|
||||||
|
pointer-events: none;
|
||||||
|
top: 0px;
|
||||||
|
bottom: 0px;
|
||||||
|
left: 0px;
|
||||||
|
right: 0px;
|
||||||
|
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
div.error-wrapper {
|
||||||
|
pointer-events: auto;
|
||||||
|
width: 400px;
|
||||||
|
box-shadow: 0px 0px 5px rgba(0, 0, 0, .25);
|
||||||
|
padding: 5px;
|
||||||
|
background-color: $error-color;
|
||||||
|
border: 1px solid $error-color-dark;
|
||||||
|
color: white;
|
||||||
|
margin: auto;
|
||||||
|
margin-top: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
.feather-icon {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Login Screen
|
* Login Screen
|
||||||
*/
|
*/
|
||||||
|
@ -177,6 +221,11 @@ table.events-table {
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
video {
|
||||||
|
max-width: 90%;
|
||||||
|
max-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
td:nth-child(1) {
|
td:nth-child(1) {
|
||||||
width: 10%;
|
width: 10%;
|
||||||
max-width: 100px;
|
max-width: 100px;
|
||||||
|
|
Loading…
Reference in New Issue
Block a user