Compare commits

..

7 Commits

10 changed files with 161 additions and 9 deletions

24
README.md Normal file
View 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
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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

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

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

View File

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

View File

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

View File

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