First working prototype. (#1)
* Implement OAuth2 authentication flow. * Boostrap styling * @vjousse review.
This commit is contained in:
parent
44cc09a142
commit
f03bd90a21
5
.gitignore
vendored
5
.gitignore
vendored
@ -1 +1,4 @@
|
||||
elm-stuff
|
||||
/build
|
||||
/elm-stuff
|
||||
/node_modules
|
||||
/app.js
|
||||
|
322
Main.elm
Normal file
322
Main.elm
Normal file
@ -0,0 +1,322 @@
|
||||
module Main exposing (..)
|
||||
|
||||
import Json.Encode as Encode
|
||||
import Html exposing (..)
|
||||
import Html.Attributes exposing (..)
|
||||
import Html.Events exposing (..)
|
||||
import HtmlParser
|
||||
import HtmlParser.Util exposing (textContent)
|
||||
import Navigation
|
||||
import Ports
|
||||
import Mastodon
|
||||
|
||||
|
||||
type alias Flags =
|
||||
{ client : Maybe Mastodon.Client
|
||||
, registration : Maybe Mastodon.AppRegistration
|
||||
}
|
||||
|
||||
|
||||
type Msg
|
||||
= AccessToken (Result Mastodon.Error Mastodon.AccessTokenResult)
|
||||
| AppRegistered (Result Mastodon.Error Mastodon.AppRegistration)
|
||||
| LocalTimeline (Result Mastodon.Error (List Mastodon.Status))
|
||||
| PublicTimeline (Result Mastodon.Error (List Mastodon.Status))
|
||||
| Register
|
||||
| ServerChange String
|
||||
| UrlChange Navigation.Location
|
||||
| UserTimeline (Result Mastodon.Error (List Mastodon.Status))
|
||||
|
||||
|
||||
type alias Model =
|
||||
{ server : String
|
||||
, registration : Maybe Mastodon.AppRegistration
|
||||
, client : Maybe Mastodon.Client
|
||||
, userTimeline : List Mastodon.Status
|
||||
, localTimeline : List Mastodon.Status
|
||||
, publicTimeline : List Mastodon.Status
|
||||
, errors : List String
|
||||
, location : Navigation.Location
|
||||
}
|
||||
|
||||
|
||||
extractAuthCode : Navigation.Location -> Maybe String
|
||||
extractAuthCode { search } =
|
||||
case (String.split "?code=" search) of
|
||||
[ _, authCode ] ->
|
||||
Just authCode
|
||||
|
||||
_ ->
|
||||
Nothing
|
||||
|
||||
|
||||
init : Flags -> Navigation.Location -> ( Model, Cmd Msg )
|
||||
init flags location =
|
||||
let
|
||||
authCode =
|
||||
extractAuthCode location
|
||||
in
|
||||
{ server = ""
|
||||
, registration = flags.registration
|
||||
, client = flags.client
|
||||
, userTimeline = []
|
||||
, localTimeline = []
|
||||
, publicTimeline = []
|
||||
, errors = []
|
||||
, location = location
|
||||
}
|
||||
! [ initCommands flags.registration flags.client authCode ]
|
||||
|
||||
|
||||
initCommands : Maybe Mastodon.AppRegistration -> Maybe Mastodon.Client -> Maybe String -> Cmd Msg
|
||||
initCommands registration client authCode =
|
||||
Cmd.batch <|
|
||||
case authCode of
|
||||
Just authCode ->
|
||||
case registration of
|
||||
Just registration ->
|
||||
[ Mastodon.getAccessToken registration authCode |> Mastodon.send AccessToken ]
|
||||
|
||||
Nothing ->
|
||||
[]
|
||||
|
||||
Nothing ->
|
||||
case client of
|
||||
Just client ->
|
||||
[ loadTimelines client ]
|
||||
|
||||
Nothing ->
|
||||
[]
|
||||
|
||||
|
||||
registerApp : Model -> Cmd Msg
|
||||
registerApp { server, location } =
|
||||
let
|
||||
appUrl =
|
||||
location.origin ++ location.pathname
|
||||
in
|
||||
Mastodon.register
|
||||
server
|
||||
"tooty"
|
||||
appUrl
|
||||
"read write follow"
|
||||
|> Mastodon.send AppRegistered
|
||||
|
||||
|
||||
saveClient : Mastodon.Client -> Cmd Msg
|
||||
saveClient client =
|
||||
Mastodon.clientEncoder client
|
||||
|> Encode.encode 0
|
||||
|> Ports.saveClient
|
||||
|
||||
|
||||
saveRegistration : Mastodon.AppRegistration -> Cmd Msg
|
||||
saveRegistration registration =
|
||||
Mastodon.registrationEncoder registration
|
||||
|> Encode.encode 0
|
||||
|> Ports.saveRegistration
|
||||
|
||||
|
||||
loadTimelines : Mastodon.Client -> Cmd Msg
|
||||
loadTimelines client =
|
||||
Cmd.batch
|
||||
[ Mastodon.fetchUserTimeline client |> Mastodon.send UserTimeline
|
||||
, Mastodon.fetchLocalTimeline client |> Mastodon.send LocalTimeline
|
||||
, Mastodon.fetchPublicTimeline client |> Mastodon.send PublicTimeline
|
||||
]
|
||||
|
||||
|
||||
errorText : Mastodon.Error -> String
|
||||
errorText error =
|
||||
case error of
|
||||
Mastodon.MastodonError statusCode statusMsg errorMsg ->
|
||||
"HTTP " ++ (toString statusCode) ++ " " ++ statusMsg ++ ": " ++ errorMsg
|
||||
|
||||
Mastodon.ServerError statusCode statusMsg errorMsg ->
|
||||
"HTTP " ++ (toString statusCode) ++ " " ++ statusMsg ++ ": " ++ errorMsg
|
||||
|
||||
Mastodon.TimeoutError ->
|
||||
"Request timed out."
|
||||
|
||||
Mastodon.NetworkError ->
|
||||
"Unreachable host."
|
||||
|
||||
|
||||
update : Msg -> Model -> ( Model, Cmd Msg )
|
||||
update msg model =
|
||||
case msg of
|
||||
ServerChange server ->
|
||||
{ model | server = server } ! []
|
||||
|
||||
UrlChange location ->
|
||||
model ! []
|
||||
|
||||
Register ->
|
||||
model ! [ registerApp model ]
|
||||
|
||||
AppRegistered result ->
|
||||
case result of
|
||||
Ok registration ->
|
||||
{ model | registration = Just registration }
|
||||
! [ saveRegistration registration
|
||||
, Navigation.load <| Mastodon.getAuthorizationUrl registration
|
||||
]
|
||||
|
||||
Err error ->
|
||||
{ model | errors = (errorText error) :: model.errors } ! []
|
||||
|
||||
AccessToken result ->
|
||||
case result of
|
||||
Ok { server, accessToken } ->
|
||||
let
|
||||
client =
|
||||
Mastodon.Client server accessToken
|
||||
in
|
||||
{ model | client = Just client }
|
||||
! [ loadTimelines client
|
||||
, Navigation.modifyUrl model.location.pathname
|
||||
, saveClient client
|
||||
]
|
||||
|
||||
Err error ->
|
||||
{ model | errors = (errorText error) :: model.errors } ! []
|
||||
|
||||
UserTimeline result ->
|
||||
case result of
|
||||
Ok userTimeline ->
|
||||
{ model | userTimeline = userTimeline } ! []
|
||||
|
||||
Err error ->
|
||||
{ model | userTimeline = [], errors = (errorText error) :: model.errors } ! []
|
||||
|
||||
LocalTimeline result ->
|
||||
case result of
|
||||
Ok localTimeline ->
|
||||
{ model | localTimeline = localTimeline } ! []
|
||||
|
||||
Err error ->
|
||||
{ model | localTimeline = [], errors = (errorText error) :: model.errors } ! []
|
||||
|
||||
PublicTimeline result ->
|
||||
case result of
|
||||
Ok publicTimeline ->
|
||||
{ model | publicTimeline = publicTimeline } ! []
|
||||
|
||||
Err error ->
|
||||
{ model | publicTimeline = [], errors = (errorText error) :: model.errors } ! []
|
||||
|
||||
|
||||
errorView : String -> Html Msg
|
||||
errorView error =
|
||||
div [ class "alert alert-danger" ] [ text error ]
|
||||
|
||||
|
||||
errorsListView : Model -> Html Msg
|
||||
errorsListView model =
|
||||
case model.errors of
|
||||
[] ->
|
||||
text ""
|
||||
|
||||
errors ->
|
||||
div [] <| List.map errorView model.errors
|
||||
|
||||
|
||||
statusView : Mastodon.Status -> Html Msg
|
||||
statusView status =
|
||||
case status.reblog of
|
||||
Just (Mastodon.Reblog reblog) ->
|
||||
div [ class "reblog" ]
|
||||
[ p []
|
||||
[ a [ href status.account.url ] [ text <| "@" ++ status.account.username ]
|
||||
, text " reblogged"
|
||||
]
|
||||
, statusView reblog
|
||||
]
|
||||
|
||||
Nothing ->
|
||||
div [ class "status" ]
|
||||
[ img [ class "avatar", src status.account.avatar ] []
|
||||
, div [ class "username" ] [ text status.account.username ]
|
||||
, div [ class "status-text" ]
|
||||
[ HtmlParser.parse status.content |> textContent |> text ]
|
||||
]
|
||||
|
||||
|
||||
timelineView : List Mastodon.Status -> String -> Html Msg
|
||||
timelineView statuses label =
|
||||
div [ class "col-sm-4" ]
|
||||
[ div [ class "panel panel-default" ]
|
||||
[ div [ class "panel-heading" ] [ text label ]
|
||||
, ul [ class "list-group" ] <|
|
||||
List.map
|
||||
(\s ->
|
||||
li [ class "list-group-item status" ]
|
||||
[ statusView s ]
|
||||
)
|
||||
statuses
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
homepageView : Model -> Html Msg
|
||||
homepageView model =
|
||||
div [ class "row" ]
|
||||
[ timelineView model.userTimeline "Home timeline"
|
||||
, timelineView model.localTimeline "Local timeline"
|
||||
, timelineView model.publicTimeline "Public timeline"
|
||||
]
|
||||
|
||||
|
||||
authView : Model -> Html Msg
|
||||
authView model =
|
||||
div [ class "col-md-4 col-md-offset-4" ]
|
||||
[ div [ class "panel panel-default" ]
|
||||
[ div [ class "panel-heading" ] [ text "Authenticate" ]
|
||||
, div [ class "panel-body" ]
|
||||
[ Html.form [ class "form", onSubmit Register ]
|
||||
[ div [ class "form-group" ]
|
||||
[ label [ for "server" ] [ text "Mastodon server root URL" ]
|
||||
, input
|
||||
[ type_ "url"
|
||||
, class "form-control"
|
||||
, id "server"
|
||||
, required True
|
||||
, placeholder "https://mastodon.social"
|
||||
, value model.server
|
||||
, pattern "https://.+"
|
||||
, onInput ServerChange
|
||||
]
|
||||
[]
|
||||
, p [ class "help-block" ]
|
||||
[ text "You'll be redirected to that server to authenticate yourself. We don't have access to your password." ]
|
||||
]
|
||||
, button [ class "btn btn-primary", type_ "submit" ]
|
||||
[ text "Sign into Tooty" ]
|
||||
]
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
view : Model -> Html Msg
|
||||
view model =
|
||||
div [ class "container-fluid" ]
|
||||
[ h1 [] [ text "tooty" ]
|
||||
, errorsListView model
|
||||
, case model.client of
|
||||
Just client ->
|
||||
homepageView model
|
||||
|
||||
Nothing ->
|
||||
authView model
|
||||
]
|
||||
|
||||
|
||||
main : Program Flags Model Msg
|
||||
main =
|
||||
Navigation.programWithFlags UrlChange
|
||||
{ init = init
|
||||
, view = view
|
||||
, update = update
|
||||
, subscriptions = always Sub.none
|
||||
}
|
411
Mastodon.elm
Normal file
411
Mastodon.elm
Normal file
@ -0,0 +1,411 @@
|
||||
module Mastodon
|
||||
exposing
|
||||
( AccessTokenResult
|
||||
, Account
|
||||
, AppRegistration
|
||||
, Attachment
|
||||
, Client
|
||||
, Error(..)
|
||||
, Mention
|
||||
, Reblog(..)
|
||||
, Status
|
||||
, Tag
|
||||
, register
|
||||
, registrationEncoder
|
||||
, clientEncoder
|
||||
, getAuthorizationUrl
|
||||
, getAccessToken
|
||||
, fetchPublicTimeline
|
||||
, fetchLocalTimeline
|
||||
, fetchUserTimeline
|
||||
, send
|
||||
)
|
||||
|
||||
import Http
|
||||
import HttpBuilder
|
||||
import Json.Decode.Pipeline as Pipe
|
||||
import Json.Decode as Decode
|
||||
import Json.Encode as Encode
|
||||
|
||||
|
||||
-- Types
|
||||
|
||||
|
||||
type alias Server =
|
||||
String
|
||||
|
||||
|
||||
type alias AuthCode =
|
||||
String
|
||||
|
||||
|
||||
type alias ClientId =
|
||||
String
|
||||
|
||||
|
||||
type alias ClientSecret =
|
||||
String
|
||||
|
||||
|
||||
type alias StatusCode =
|
||||
Int
|
||||
|
||||
|
||||
type alias StatusMsg =
|
||||
String
|
||||
|
||||
|
||||
type alias Token =
|
||||
String
|
||||
|
||||
|
||||
type alias Client =
|
||||
{ server : Server
|
||||
, token : Token
|
||||
}
|
||||
|
||||
|
||||
type Error
|
||||
= MastodonError StatusCode StatusMsg String
|
||||
| ServerError StatusCode StatusMsg String
|
||||
| TimeoutError
|
||||
| NetworkError
|
||||
|
||||
|
||||
type alias AppRegistration =
|
||||
{ server : Server
|
||||
, scope : String
|
||||
, client_id : ClientId
|
||||
, client_secret : ClientSecret
|
||||
, id : Int
|
||||
, redirect_uri : String
|
||||
}
|
||||
|
||||
|
||||
type alias Account =
|
||||
{ acct : String
|
||||
, avatar : String
|
||||
, created_at : String
|
||||
, display_name : String
|
||||
, followers_count : Int
|
||||
, following_count : Int
|
||||
, header : String
|
||||
, id : Int
|
||||
, locked : Bool
|
||||
, note : String
|
||||
, statuses_count : Int
|
||||
, url : String
|
||||
, username : String
|
||||
}
|
||||
|
||||
|
||||
type alias Attachment =
|
||||
{ id : Int
|
||||
, -- Type: "image", "video", "gifv"
|
||||
type_ : String
|
||||
, url : String
|
||||
, remote_url : String
|
||||
, preview_url : String
|
||||
, text_url : Maybe String
|
||||
}
|
||||
|
||||
|
||||
type alias Mention =
|
||||
{ id : Int
|
||||
, url : String
|
||||
, username : String
|
||||
, acct : String
|
||||
}
|
||||
|
||||
|
||||
type alias Tag =
|
||||
{ name : String
|
||||
, url : String
|
||||
}
|
||||
|
||||
|
||||
type alias Status =
|
||||
{ account : Account
|
||||
, content : String
|
||||
, created_at : String
|
||||
, favourited : Maybe Bool
|
||||
, favourites_count : Int
|
||||
, id : Int
|
||||
, in_reply_to_account_id : Maybe Int
|
||||
, in_reply_to_id : Maybe Int
|
||||
, media_attachments : List Attachment
|
||||
, mentions : List Mention
|
||||
, reblog : Maybe Reblog
|
||||
, reblogged : Maybe Bool
|
||||
, reblogs_count : Int
|
||||
, sensitive : Maybe Bool
|
||||
, spoiler_text : String
|
||||
, tags : List Tag
|
||||
, uri : String
|
||||
, url : String
|
||||
, visibility : String
|
||||
}
|
||||
|
||||
|
||||
type Reblog
|
||||
= Reblog Status
|
||||
|
||||
|
||||
|
||||
-- Msg
|
||||
|
||||
|
||||
type StatusListResult
|
||||
= Result Http.Error (List Status)
|
||||
|
||||
|
||||
type alias AccessTokenResult =
|
||||
{ server : Server
|
||||
, accessToken : Token
|
||||
}
|
||||
|
||||
|
||||
|
||||
-- Encoders
|
||||
|
||||
|
||||
appRegistrationEncoder : String -> String -> String -> Encode.Value
|
||||
appRegistrationEncoder client_name redirect_uris scope =
|
||||
Encode.object
|
||||
[ ( "client_name", Encode.string client_name )
|
||||
, ( "redirect_uris", Encode.string redirect_uris )
|
||||
, ( "scopes", Encode.string scope )
|
||||
]
|
||||
|
||||
|
||||
authorizationCodeEncoder : AppRegistration -> AuthCode -> Encode.Value
|
||||
authorizationCodeEncoder registration authCode =
|
||||
Encode.object
|
||||
[ ( "client_id", Encode.string registration.client_id )
|
||||
, ( "client_secret", Encode.string registration.client_secret )
|
||||
, ( "grant_type", Encode.string "authorization_code" )
|
||||
, ( "redirect_uri", Encode.string registration.redirect_uri )
|
||||
, ( "code", Encode.string authCode )
|
||||
]
|
||||
|
||||
|
||||
|
||||
-- Decoders
|
||||
|
||||
|
||||
appRegistrationDecoder : Server -> String -> Decode.Decoder AppRegistration
|
||||
appRegistrationDecoder server scope =
|
||||
Pipe.decode AppRegistration
|
||||
|> Pipe.hardcoded server
|
||||
|> Pipe.hardcoded scope
|
||||
|> Pipe.required "client_id" Decode.string
|
||||
|> Pipe.required "client_secret" Decode.string
|
||||
|> Pipe.required "id" Decode.int
|
||||
|> Pipe.required "redirect_uri" Decode.string
|
||||
|
||||
|
||||
accessTokenDecoder : AppRegistration -> Decode.Decoder AccessTokenResult
|
||||
accessTokenDecoder registration =
|
||||
Pipe.decode AccessTokenResult
|
||||
|> Pipe.hardcoded registration.server
|
||||
|> Pipe.required "access_token" Decode.string
|
||||
|
||||
|
||||
accountDecoder : Decode.Decoder Account
|
||||
accountDecoder =
|
||||
Pipe.decode Account
|
||||
|> Pipe.required "acct" Decode.string
|
||||
|> Pipe.required "avatar" Decode.string
|
||||
|> Pipe.required "created_at" Decode.string
|
||||
|> Pipe.required "display_name" Decode.string
|
||||
|> Pipe.required "followers_count" Decode.int
|
||||
|> Pipe.required "following_count" Decode.int
|
||||
|> Pipe.required "header" Decode.string
|
||||
|> Pipe.required "id" Decode.int
|
||||
|> Pipe.required "locked" Decode.bool
|
||||
|> Pipe.required "note" Decode.string
|
||||
|> Pipe.required "statuses_count" Decode.int
|
||||
|> Pipe.required "url" Decode.string
|
||||
|> Pipe.required "username" Decode.string
|
||||
|
||||
|
||||
attachmentDecoder : Decode.Decoder Attachment
|
||||
attachmentDecoder =
|
||||
Pipe.decode Attachment
|
||||
|> Pipe.required "id" Decode.int
|
||||
|> Pipe.required "type" Decode.string
|
||||
|> Pipe.required "url" Decode.string
|
||||
|> Pipe.required "remote_url" Decode.string
|
||||
|> Pipe.required "preview_url" Decode.string
|
||||
|> Pipe.required "text_url" (Decode.nullable Decode.string)
|
||||
|
||||
|
||||
mentionDecoder : Decode.Decoder Mention
|
||||
mentionDecoder =
|
||||
Pipe.decode Mention
|
||||
|> Pipe.required "id" Decode.int
|
||||
|> Pipe.required "url" Decode.string
|
||||
|> Pipe.required "username" Decode.string
|
||||
|> Pipe.required "acct" Decode.string
|
||||
|
||||
|
||||
tagDecoder : Decode.Decoder Tag
|
||||
tagDecoder =
|
||||
Pipe.decode Tag
|
||||
|> Pipe.required "name" Decode.string
|
||||
|> Pipe.required "url" Decode.string
|
||||
|
||||
|
||||
reblogDecoder : Decode.Decoder Reblog
|
||||
reblogDecoder =
|
||||
Decode.map Reblog (Decode.lazy (\_ -> statusDecoder))
|
||||
|
||||
|
||||
statusDecoder : Decode.Decoder Status
|
||||
statusDecoder =
|
||||
Pipe.decode Status
|
||||
|> Pipe.required "account" accountDecoder
|
||||
|> Pipe.required "content" Decode.string
|
||||
|> Pipe.required "created_at" Decode.string
|
||||
|> Pipe.optional "favourited" (Decode.nullable Decode.bool) Nothing
|
||||
|> Pipe.required "favourites_count" Decode.int
|
||||
|> Pipe.required "id" Decode.int
|
||||
|> Pipe.required "in_reply_to_account_id" (Decode.nullable Decode.int)
|
||||
|> Pipe.required "in_reply_to_id" (Decode.nullable Decode.int)
|
||||
|> Pipe.required "media_attachments" (Decode.list attachmentDecoder)
|
||||
|> Pipe.required "mentions" (Decode.list mentionDecoder)
|
||||
|> Pipe.optional "reblog" (Decode.nullable reblogDecoder) Nothing
|
||||
|> Pipe.optional "reblogged" (Decode.nullable Decode.bool) Nothing
|
||||
|> Pipe.required "reblogs_count" Decode.int
|
||||
|> Pipe.required "sensitive" (Decode.nullable Decode.bool)
|
||||
|> Pipe.required "spoiler_text" Decode.string
|
||||
|> Pipe.required "tags" (Decode.list tagDecoder)
|
||||
|> Pipe.required "uri" Decode.string
|
||||
|> Pipe.required "url" Decode.string
|
||||
|> Pipe.required "visibility" Decode.string
|
||||
|
||||
|
||||
|
||||
-- Internal helpers
|
||||
|
||||
|
||||
encodeUrl : String -> List ( String, String ) -> String
|
||||
encodeUrl base params =
|
||||
List.map (\( k, v ) -> k ++ "=" ++ Http.encodeUri v) params
|
||||
|> String.join "&"
|
||||
|> (++) (base ++ "?")
|
||||
|
||||
|
||||
mastodonErrorDecoder : Decode.Decoder String
|
||||
mastodonErrorDecoder =
|
||||
Decode.field "error" Decode.string
|
||||
|
||||
|
||||
extractMastodonError : StatusCode -> StatusMsg -> String -> Error
|
||||
extractMastodonError statusCode statusMsg body =
|
||||
case Decode.decodeString mastodonErrorDecoder body of
|
||||
Ok errRecord ->
|
||||
MastodonError statusCode statusMsg errRecord
|
||||
|
||||
Err err ->
|
||||
ServerError statusCode statusMsg err
|
||||
|
||||
|
||||
extractError : Http.Error -> Error
|
||||
extractError error =
|
||||
case error of
|
||||
Http.BadStatus { status, body } ->
|
||||
extractMastodonError status.code status.message body
|
||||
|
||||
Http.BadPayload str { status } ->
|
||||
ServerError
|
||||
status.code
|
||||
status.message
|
||||
("Failed decoding JSON: " ++ str)
|
||||
|
||||
Http.Timeout ->
|
||||
TimeoutError
|
||||
|
||||
_ ->
|
||||
NetworkError
|
||||
|
||||
|
||||
toResponse : Result Http.Error a -> Result Error a
|
||||
toResponse result =
|
||||
Result.mapError extractError result
|
||||
|
||||
|
||||
fetchStatusList : Client -> String -> HttpBuilder.RequestBuilder (List Status)
|
||||
fetchStatusList client endpoint =
|
||||
HttpBuilder.get (client.server ++ endpoint)
|
||||
|> HttpBuilder.withHeader "Authorization" ("Bearer " ++ client.token)
|
||||
|> HttpBuilder.withExpect (Http.expectJson (Decode.list statusDecoder))
|
||||
|
||||
|
||||
|
||||
-- Public API
|
||||
|
||||
|
||||
clientEncoder : Client -> Encode.Value
|
||||
clientEncoder client =
|
||||
Encode.object
|
||||
[ ( "server", Encode.string client.server )
|
||||
, ( "token", Encode.string client.token )
|
||||
]
|
||||
|
||||
|
||||
registrationEncoder : AppRegistration -> Encode.Value
|
||||
registrationEncoder registration =
|
||||
Encode.object
|
||||
[ ( "server", Encode.string registration.server )
|
||||
, ( "scope", Encode.string registration.scope )
|
||||
, ( "client_id", Encode.string registration.client_id )
|
||||
, ( "client_secret", Encode.string registration.client_secret )
|
||||
, ( "id", Encode.int registration.id )
|
||||
, ( "redirect_uri", Encode.string registration.redirect_uri )
|
||||
]
|
||||
|
||||
|
||||
register : Server -> String -> String -> String -> HttpBuilder.RequestBuilder AppRegistration
|
||||
register server client_name redirect_uri scope =
|
||||
HttpBuilder.post (server ++ "/api/v1/apps")
|
||||
|> HttpBuilder.withExpect (Http.expectJson (appRegistrationDecoder server scope))
|
||||
|> HttpBuilder.withJsonBody (appRegistrationEncoder client_name redirect_uri scope)
|
||||
|
||||
|
||||
getAuthorizationUrl : AppRegistration -> String
|
||||
getAuthorizationUrl registration =
|
||||
encodeUrl (registration.server ++ "/oauth/authorize")
|
||||
[ ( "response_type", "code" )
|
||||
, ( "client_id", registration.client_id )
|
||||
, ( "scope", registration.scope )
|
||||
, ( "redirect_uri", registration.redirect_uri )
|
||||
]
|
||||
|
||||
|
||||
getAccessToken : AppRegistration -> AuthCode -> HttpBuilder.RequestBuilder AccessTokenResult
|
||||
getAccessToken registration authCode =
|
||||
HttpBuilder.post (registration.server ++ "/oauth/token")
|
||||
|> HttpBuilder.withExpect (Http.expectJson (accessTokenDecoder registration))
|
||||
|> HttpBuilder.withJsonBody (authorizationCodeEncoder registration authCode)
|
||||
|
||||
|
||||
send : (Result Error a -> msg) -> HttpBuilder.RequestBuilder a -> Cmd msg
|
||||
send tagger builder =
|
||||
builder
|
||||
|> HttpBuilder.send (toResponse >> tagger)
|
||||
|
||||
|
||||
fetchUserTimeline : Client -> HttpBuilder.RequestBuilder (List Status)
|
||||
fetchUserTimeline client =
|
||||
fetchStatusList client "/api/v1/timelines/home"
|
||||
|
||||
|
||||
fetchLocalTimeline : Client -> HttpBuilder.RequestBuilder (List Status)
|
||||
fetchLocalTimeline client =
|
||||
fetchStatusList client "/api/v1/timelines/public?local=true"
|
||||
|
||||
|
||||
fetchPublicTimeline : Client -> HttpBuilder.RequestBuilder (List Status)
|
||||
fetchPublicTimeline client =
|
||||
fetchStatusList client "/api/v1/timelines/public"
|
7
Ports.elm
Normal file
7
Ports.elm
Normal file
@ -0,0 +1,7 @@
|
||||
port module Ports exposing (saveRegistration, saveClient)
|
||||
|
||||
|
||||
port saveRegistration : String -> Cmd msg
|
||||
|
||||
|
||||
port saveClient : String -> Cmd msg
|
26
README.md
26
README.md
@ -1,3 +1,27 @@
|
||||
# tooty
|
||||
|
||||
A Mastodon client written in Elm.
|
||||
An [experimental Mastodon client](https://n1k0.github.io/tooty/) written in Elm. It is not usable yet.
|
||||
|
||||
![](http://i.imgur.com/nR843q3.png)
|
||||
|
||||
### Setting up the development environment
|
||||
|
||||
$ npm i
|
||||
|
||||
### Starting the dev server
|
||||
|
||||
$ npm run live
|
||||
|
||||
### Building
|
||||
|
||||
$ npm run build
|
||||
|
||||
### Deploying to gh-pages
|
||||
|
||||
$ npm run deploy
|
||||
|
||||
The app should be deployed to https://n1k0.github.io/tooty/
|
||||
|
||||
## Licence
|
||||
|
||||
MIT
|
||||
|
@ -8,8 +8,14 @@
|
||||
],
|
||||
"exposed-modules": [],
|
||||
"dependencies": {
|
||||
"NoRedInk/elm-decode-pipeline": "3.0.0 <= v < 4.0.0",
|
||||
"elm-lang/core": "5.1.1 <= v < 6.0.0",
|
||||
"elm-lang/html": "2.0.0 <= v < 3.0.0"
|
||||
"elm-lang/html": "2.0.0 <= v < 3.0.0",
|
||||
"elm-lang/http": "1.0.0 <= v < 2.0.0",
|
||||
"elm-lang/navigation": "2.1.0 <= v < 3.0.0",
|
||||
"evancz/url-parser": "2.0.1 <= v < 3.0.0",
|
||||
"jinjor/elm-html-parser": "1.1.4 <= v < 2.0.0",
|
||||
"lukewestby/elm-http-builder": "5.1.0 <= v < 6.0.0"
|
||||
},
|
||||
"elm-version": "0.18.0 <= v < 0.19.0"
|
||||
}
|
||||
|
26
index.html
Normal file
26
index.html
Normal file
@ -0,0 +1,26 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Tooty</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link href="//bootswatch.com/slate/bootstrap.min.css" media="all" rel="stylesheet" />
|
||||
<link href="style.css" media="all" rel="stylesheet" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<script src="app.js"></script>
|
||||
<script>
|
||||
const app = Elm.Main.fullscreen({
|
||||
client: JSON.parse(localStorage.getItem("tooty.client")),
|
||||
registration: JSON.parse(localStorage.getItem("tooty.registration"))
|
||||
});
|
||||
app.ports.saveClient.subscribe(json => {
|
||||
localStorage.setItem("tooty.client", json);
|
||||
});
|
||||
app.ports.saveRegistration.subscribe(json => {
|
||||
localStorage.setItem("tooty.registration", json);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
31
package.json
Normal file
31
package.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "tooty",
|
||||
"version": "1.0.0",
|
||||
"description": "An alternative Web client for Mastodon.",
|
||||
"scripts": {
|
||||
"build": "node_modules/.bin/elm-make Main.elm --output=build/app.js && npm run copy-assets",
|
||||
"copy-assets": "node_modules/.bin/copyfiles index.html style.css build/",
|
||||
"deploy": "npm run build && node_modules/.bin/gh-pages --dist build/",
|
||||
"live": "node_modules/.bin/elm-live Main.elm --output=app.js --debug",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/n1k0/tooty.git"
|
||||
},
|
||||
"keywords": [
|
||||
"mastodon"
|
||||
],
|
||||
"author": "n1k0 <nicolas@perriault.net>",
|
||||
"license": "MIT",
|
||||
"bugs": {
|
||||
"url": "https://github.com/n1k0/tooty/issues"
|
||||
},
|
||||
"homepage": "https://github.com/n1k0/tooty#readme",
|
||||
"devDependencies": {
|
||||
"copyfiles": "^1.2.0",
|
||||
"elm": "^0.18.0",
|
||||
"elm-live": "^2.7.4",
|
||||
"gh-pages": "^0.12.0"
|
||||
}
|
||||
}
|
27
style.css
Normal file
27
style.css
Normal file
@ -0,0 +1,27 @@
|
||||
.status {
|
||||
min-height: 75px;
|
||||
}
|
||||
|
||||
.reblog > p:first-of-type {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.panel-heading {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
display: block;
|
||||
float: left;
|
||||
width: 17%;
|
||||
border-radius: 50%;
|
||||
margin-right: .5em;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
margin-left: 1em;
|
||||
}
|
Loading…
Reference in New Issue
Block a user