WiP: Add status action buttons. (#32)

* Add status action buttons.
* Handle favorite actions.
* Handle reblog actions.
* Optimistic updates for reblogs.
This commit is contained in:
Nicolas Perriault 2017-04-23 10:18:47 +02:00 committed by GitHub
parent 82ada5ede8
commit 225a95a637
8 changed files with 391 additions and 23 deletions

View File

@ -10,7 +10,11 @@ An [experimental Mastodon client](https://n1k0.github.io/tooty/) written in Elm.
### Starting the dev server ### Starting the dev server
$ npm run live $ npm start
### Starting the dev server in live debug mode
$ npm run debug
### Building ### Building

View File

@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"NoRedInk/elm-decode-pipeline": "3.0.0 <= v < 4.0.0", "NoRedInk/elm-decode-pipeline": "3.0.0 <= v < 4.0.0",
"elm-lang/core": "5.1.1 <= v < 6.0.0", "elm-lang/core": "5.1.1 <= v < 6.0.0",
"elm-lang/dom": "1.1.1 <= v < 2.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/http": "1.0.0 <= v < 2.0.0",
"elm-lang/navigation": "2.1.0 <= v < 3.0.0", "elm-lang/navigation": "2.1.0 <= v < 3.0.0",

View File

@ -5,8 +5,9 @@
"scripts": { "scripts": {
"build": "node_modules/.bin/elm-make src/Main.elm --warn --output=build/app.js && npm run copy-assets", "build": "node_modules/.bin/elm-make src/Main.elm --warn --output=build/app.js && npm run copy-assets",
"copy-assets": "cp public/index.html build/ && cp public/style.css build/", "copy-assets": "cp public/index.html build/ && cp public/style.css build/",
"debug": "node_modules/.bin/elm-live src/Main.elm --dir=public/ --output=public/app.js --debug",
"deploy": "npm run build && node_modules/.bin/gh-pages --dist build/", "deploy": "npm run build && node_modules/.bin/gh-pages --dist build/",
"live": "node_modules/.bin/elm-live src/Main.elm --dir=public/ --output=public/app.js --debug", "start": "node_modules/.bin/elm-live src/Main.elm --dir=public/ --output=public/app.js",
"test": "node_modules/.bin/elm-make src/Main.elm --warn --output /tmp/tooty.html" "test": "node_modules/.bin/elm-make src/Main.elm --warn --output /tmp/tooty.html"
}, },
"repository": { "repository": {

View File

@ -15,6 +15,7 @@ body {
.reblog > p:first-of-type, .reblog > p:first-of-type,
.notification > p:first-of-type { .notification > p:first-of-type {
color: #999; color: #999;
margin-bottom: 8px;
} }
.reblog > p:first-of-type > a, .reblog > p:first-of-type > a,
.notification > p:first-of-type > a { .notification > p:first-of-type > a {
@ -68,10 +69,37 @@ body {
color: #9baec8; color: #9baec8;
} }
/* Status actions */
.actions {
margin-left: 65px;
width: calc(100% - 65px);
}
.actions > .btn {
border: none;
background: transparent;
color: #aaa;
padding: 0 2.4em 0 0;
text-align: left;
}
.actions > .btn > .glyphicon {
margin-right: 5px;
}
.actions .favourited {
color: #d1ac0e;
}
.actions .reblogged {
color: #d56344;
}
/* Attachments */ /* Attachments */
.attachments { .attachments {
margin: 0; margin: 0 0 15px 0;
padding: 0; padding: 0;
list-style-type: none; list-style-type: none;
overflow: hidden; overflow: hidden;
@ -151,6 +179,20 @@ body {
transition: all .6s; transition: all .6s;
} }
/* Draft form */
.in-reply-to .attachments {
margin: 0;
}
.in-reply-to .attachments li {
display: none;
}
.in-reply-to .attachments:after {
content: "[attachments hidden]";
font-size: .9em;
color: #555;
}
/* Status text content rules */ /* Status text content rules */
.attachment { .attachment {

View File

@ -12,6 +12,11 @@ module Mastodon
, Status , Status
, StatusRequestBody , StatusRequestBody
, Tag , Tag
, reblog
, unreblog
, favourite
, unfavourite
, extractReblog
, register , register
, registrationEncoder , registrationEncoder
, clientEncoder , clientEncoder
@ -406,6 +411,16 @@ extractError error =
NetworkError NetworkError
extractReblog : Status -> Status
extractReblog status =
case status.reblog of
Just (Reblog reblog) ->
reblog
Nothing ->
status
toResponse : Result Http.Error a -> Result Error a toResponse : Result Http.Error a -> Result Error a
toResponse result = toResponse result =
Result.mapError extractError result Result.mapError extractError result
@ -502,3 +517,31 @@ postStatus client statusRequestBody =
|> HttpBuilder.withHeader "Authorization" ("Bearer " ++ client.token) |> HttpBuilder.withHeader "Authorization" ("Bearer " ++ client.token)
|> HttpBuilder.withExpect (Http.expectJson statusDecoder) |> HttpBuilder.withExpect (Http.expectJson statusDecoder)
|> HttpBuilder.withJsonBody (statusRequestBodyEncoder statusRequestBody) |> HttpBuilder.withJsonBody (statusRequestBodyEncoder statusRequestBody)
reblog : Client -> Int -> Request Status
reblog client id =
HttpBuilder.post (client.server ++ "/api/v1/statuses/" ++ (toString id) ++ "/reblog")
|> HttpBuilder.withHeader "Authorization" ("Bearer " ++ client.token)
|> HttpBuilder.withExpect (Http.expectJson statusDecoder)
unreblog : Client -> Int -> Request Status
unreblog client id =
HttpBuilder.post (client.server ++ "/api/v1/statuses/" ++ (toString id) ++ "/unreblog")
|> HttpBuilder.withHeader "Authorization" ("Bearer " ++ client.token)
|> HttpBuilder.withExpect (Http.expectJson statusDecoder)
favourite : Client -> Int -> Request Status
favourite client id =
HttpBuilder.post (client.server ++ "/api/v1/statuses/" ++ (toString id) ++ "/favourite")
|> HttpBuilder.withHeader "Authorization" ("Bearer " ++ client.token)
|> HttpBuilder.withExpect (Http.expectJson statusDecoder)
unfavourite : Client -> Int -> Request Status
unfavourite client id =
HttpBuilder.post (client.server ++ "/api/v1/statuses/" ++ (toString id) ++ "/unfavourite")
|> HttpBuilder.withHeader "Authorization" ("Bearer " ++ client.token)
|> HttpBuilder.withExpect (Http.expectJson statusDecoder)

View File

@ -1,9 +1,11 @@
module Model exposing (..) module Model exposing (..)
import Dom
import Json.Encode as Encode import Json.Encode as Encode
import Navigation import Navigation
import Mastodon import Mastodon
import Ports import Ports
import Task
type alias Flags = type alias Flags =
@ -13,22 +15,37 @@ type alias Flags =
type DraftMsg type DraftMsg
= ToggleSpoiler Bool = ClearDraft
| ClearReplyTo
| UpdateSensitive Bool | UpdateSensitive Bool
| UpdateSpoiler String | UpdateSpoiler String
| UpdateStatus String | UpdateStatus String
| UpdateVisibility String | UpdateVisibility String
| UpdateReplyTo Mastodon.Status
| ToggleSpoiler Bool
type Msg type
Msg
{-
FIXME: Mastodon server response messages should be extracted to their own
MastodonMsg type at some point.
-}
= AccessToken (Result Mastodon.Error Mastodon.AccessTokenResult) = AccessToken (Result Mastodon.Error Mastodon.AccessTokenResult)
| AddFavorite Int
| AppRegistered (Result Mastodon.Error Mastodon.AppRegistration) | AppRegistered (Result Mastodon.Error Mastodon.AppRegistration)
| DraftEvent DraftMsg | DraftEvent DraftMsg
| FavoriteAdded (Result Mastodon.Error Mastodon.Status)
| FavoriteRemoved (Result Mastodon.Error Mastodon.Status)
| LocalTimeline (Result Mastodon.Error (List Mastodon.Status)) | LocalTimeline (Result Mastodon.Error (List Mastodon.Status))
| NoOp
| Notifications (Result Mastodon.Error (List Mastodon.Notification)) | Notifications (Result Mastodon.Error (List Mastodon.Notification))
| OnLoadUserAccount Int | OnLoadUserAccount Int
| PublicTimeline (Result Mastodon.Error (List Mastodon.Status)) | PublicTimeline (Result Mastodon.Error (List Mastodon.Status))
| Reblog Int
| Reblogged (Result Mastodon.Error Mastodon.Status)
| Register | Register
| RemoveFavorite Int
| ServerChange String | ServerChange String
| StatusPosted (Result Mastodon.Error Mastodon.Status) | StatusPosted (Result Mastodon.Error Mastodon.Status)
| SubmitDraft | SubmitDraft
@ -36,9 +53,25 @@ type Msg
| UseGlobalTimeline Bool | UseGlobalTimeline Bool
| UserAccount (Result Mastodon.Error Mastodon.Account) | UserAccount (Result Mastodon.Error Mastodon.Account)
| ClearOpenedAccount | ClearOpenedAccount
| Unreblog Int
| Unreblogged (Result Mastodon.Error Mastodon.Status)
| UserTimeline (Result Mastodon.Error (List Mastodon.Status)) | UserTimeline (Result Mastodon.Error (List Mastodon.Status))
type Crud
= Add
| Remove
type alias Draft =
{ status : String
, in_reply_to : Maybe Mastodon.Status
, spoiler_text : Maybe String
, sensitive : Bool
, visibility : String
}
type alias Model = type alias Model =
{ server : String { server : String
, registration : Maybe Mastodon.AppRegistration , registration : Maybe Mastodon.AppRegistration
@ -47,7 +80,7 @@ type alias Model =
, localTimeline : List Mastodon.Status , localTimeline : List Mastodon.Status
, publicTimeline : List Mastodon.Status , publicTimeline : List Mastodon.Status
, notifications : List Mastodon.Notification , notifications : List Mastodon.Notification
, draft : Mastodon.StatusRequestBody , draft : Draft
, account : Maybe Mastodon.Account , account : Maybe Mastodon.Account
, errors : List String , errors : List String
, location : Navigation.Location , location : Navigation.Location
@ -65,10 +98,10 @@ extractAuthCode { search } =
Nothing Nothing
defaultDraft : Mastodon.StatusRequestBody defaultDraft : Draft
defaultDraft = defaultDraft =
{ status = "" { status = ""
, in_reply_to_id = Nothing , in_reply_to = Nothing
, spoiler_text = Nothing , spoiler_text = Nothing
, sensitive = False , sensitive = False
, visibility = "public" , visibility = "public"
@ -142,6 +175,16 @@ saveRegistration registration =
|> Ports.saveRegistration |> Ports.saveRegistration
loadNotifications : Maybe Mastodon.Client -> Cmd Msg
loadNotifications client =
case client of
Just client ->
Mastodon.fetchNotifications client |> Mastodon.send Notifications
Nothing ->
Cmd.none
loadTimelines : Maybe Mastodon.Client -> Cmd Msg loadTimelines : Maybe Mastodon.Client -> Cmd Msg
loadTimelines client = loadTimelines client =
case client of case client of
@ -150,7 +193,7 @@ loadTimelines client =
[ Mastodon.fetchUserTimeline client |> Mastodon.send UserTimeline [ Mastodon.fetchUserTimeline client |> Mastodon.send UserTimeline
, Mastodon.fetchLocalTimeline client |> Mastodon.send LocalTimeline , Mastodon.fetchLocalTimeline client |> Mastodon.send LocalTimeline
, Mastodon.fetchPublicTimeline client |> Mastodon.send PublicTimeline , Mastodon.fetchPublicTimeline client |> Mastodon.send PublicTimeline
, Mastodon.fetchNotifications client |> Mastodon.send Notifications , loadNotifications <| Just client
] ]
Nothing -> Nothing ->
@ -179,11 +222,56 @@ errorText error =
"Unreachable host." "Unreachable host."
updateDraft : DraftMsg -> Mastodon.StatusRequestBody -> Mastodon.StatusRequestBody toStatusRequestBody : Draft -> Mastodon.StatusRequestBody
toStatusRequestBody draft =
{ status = draft.status
, in_reply_to_id =
case draft.in_reply_to of
Just status ->
Just status.id
Nothing ->
Nothing
, spoiler_text = draft.spoiler_text
, sensitive = draft.sensitive
, visibility = draft.visibility
}
updateTimelinesWithBoolFlag : Int -> Bool -> (Mastodon.Status -> Mastodon.Status) -> Model -> Model
updateTimelinesWithBoolFlag statusId flag statusUpdater model =
let
update flag status =
if (Mastodon.extractReblog status).id == statusId then
statusUpdater status
else
status
in
{ model
| userTimeline = List.map (update flag) model.userTimeline
, localTimeline = List.map (update flag) model.localTimeline
, publicTimeline = List.map (update flag) model.publicTimeline
}
processFavourite : Int -> Bool -> Model -> Model
processFavourite statusId flag model =
updateTimelinesWithBoolFlag statusId flag (\s -> { s | favourited = Just flag }) model
processReblog : Int -> Bool -> Model -> Model
processReblog statusId flag model =
updateTimelinesWithBoolFlag statusId flag (\s -> { s | reblogged = Just flag }) model
updateDraft : DraftMsg -> Draft -> ( Draft, Cmd Msg )
updateDraft draftMsg draft = updateDraft draftMsg draft =
-- TODO: later we'll probably want to handle more events like when the user -- TODO: later we'll probably want to handle more events like when the user
-- wants to add CW, medias, etc. -- wants to add CW, medias, etc.
case draftMsg of case draftMsg of
ClearDraft ->
defaultDraft ! []
ToggleSpoiler enabled -> ToggleSpoiler enabled ->
{ draft { draft
| spoiler_text = | spoiler_text =
@ -192,23 +280,45 @@ updateDraft draftMsg draft =
else else
Nothing Nothing
} }
! []
UpdateSensitive sensitive -> UpdateSensitive sensitive ->
{ draft | sensitive = sensitive } { draft | sensitive = sensitive } ! []
UpdateSpoiler spoiler_text -> UpdateSpoiler spoiler_text ->
{ draft | spoiler_text = Just spoiler_text } { draft | spoiler_text = Just spoiler_text } ! []
UpdateStatus status -> UpdateStatus status ->
{ draft | status = status } { draft | status = status } ! []
UpdateVisibility visibility -> UpdateVisibility visibility ->
{ draft | visibility = visibility } { draft | visibility = visibility } ! []
UpdateReplyTo status ->
let
mention =
"@" ++ status.account.acct
in
{ draft
| in_reply_to = Just status
, status =
if String.startsWith mention draft.status then
draft.status
else
mention ++ " " ++ draft.status
}
! [ Dom.focus "status" |> Task.attempt (always NoOp) ]
ClearReplyTo ->
{ draft | in_reply_to = Nothing } ! []
update : Msg -> Model -> ( Model, Cmd Msg ) update : Msg -> Model -> ( Model, Cmd Msg )
update msg model = update msg model =
case msg of case msg of
NoOp ->
model ! []
ServerChange server -> ServerChange server ->
{ model | server = server } ! [] { model | server = server } ! []
@ -245,14 +355,88 @@ update msg model =
Err error -> Err error ->
{ model | errors = (errorText error) :: model.errors } ! [] { model | errors = (errorText error) :: model.errors } ! []
Reblog id ->
-- Note: The case of reblogging is specific as it seems the server
-- response takes a lot of time to be received by the client, so we
-- perform optimistic updates here.
case model.client of
Just client ->
processReblog id True model
! [ Mastodon.reblog client id |> Mastodon.send Reblogged ]
Nothing ->
model ! []
Reblogged result ->
case result of
Ok status ->
model ! [ loadNotifications model.client ]
Err error ->
{ model | errors = (errorText error) :: model.errors } ! []
Unreblog id ->
case model.client of
Just client ->
processReblog id False model ! [ Mastodon.unfavourite client id |> Mastodon.send Unreblogged ]
Nothing ->
model ! []
Unreblogged result ->
case result of
Ok status ->
model ! [ loadNotifications model.client ]
Err error ->
{ model | errors = (errorText error) :: model.errors } ! []
AddFavorite id ->
model
! case model.client of
Just client ->
[ Mastodon.favourite client id |> Mastodon.send FavoriteAdded ]
Nothing ->
[]
FavoriteAdded result ->
case result of
Ok status ->
processFavourite status.id True model ! [ loadNotifications model.client ]
Err error ->
{ model | errors = (errorText error) :: model.errors } ! []
RemoveFavorite id ->
model
! case model.client of
Just client ->
[ Mastodon.unfavourite client id |> Mastodon.send FavoriteRemoved ]
Nothing ->
[]
FavoriteRemoved result ->
case result of
Ok status ->
processFavourite status.id False model ! [ loadNotifications model.client ]
Err error ->
{ model | errors = (errorText error) :: model.errors } ! []
DraftEvent draftMsg -> DraftEvent draftMsg ->
{ model | draft = updateDraft draftMsg model.draft } ! [] let
( draft, commands ) =
updateDraft draftMsg model.draft
in
{ model | draft = draft } ! [ commands ]
SubmitDraft -> SubmitDraft ->
model model
! case model.client of ! case model.client of
Just client -> Just client ->
[ postStatus client model.draft ] [ postStatus client <| toStatusRequestBody model.draft ]
Nothing -> Nothing ->
[] []

View File

@ -5,7 +5,7 @@ import Html exposing (..)
import Html.Attributes exposing (..) import Html.Attributes exposing (..)
import Html.Events exposing (..) import Html.Events exposing (..)
import Mastodon import Mastodon
import Model exposing (Model, DraftMsg(..), Msg(..)) import Model exposing (Model, Draft, DraftMsg(..), Msg(..))
import ViewHelper import ViewHelper
@ -34,6 +34,12 @@ errorsListView model =
div [] <| List.map errorView model.errors div [] <| List.map errorView model.errors
justifiedButtonGroup : List (Html Msg) -> Html Msg
justifiedButtonGroup buttons =
div [ class "btn-group btn-group-justified" ] <|
List.map (\b -> div [ class "btn-group" ] [ b ]) buttons
icon : String -> Html Msg icon : String -> Html Msg
icon name = icon name =
i [ class <| "glyphicon glyphicon-" ++ name ] [] i [ class <| "glyphicon glyphicon-" ++ name ] []
@ -215,6 +221,51 @@ accountTimelineView account statuses label iconName =
] ]
statusActionsView : Mastodon.Status -> Html Msg
statusActionsView status =
let
target =
Mastodon.extractReblog status
baseBtnClasses =
"btn btn-sm btn-default"
( reblogClasses, reblogEvent ) =
case status.favourited of
Just True ->
( baseBtnClasses ++ " reblogged", Unreblog target.id )
_ ->
( baseBtnClasses, AddFavorite target.id )
( favClasses, favEvent ) =
case status.favourited of
Just True ->
( baseBtnClasses ++ " favourited", RemoveFavorite target.id )
_ ->
( baseBtnClasses, AddFavorite target.id )
in
div [ class "btn-group actions" ]
[ a
[ class baseBtnClasses
, ViewHelper.onClickWithPreventAndStop <|
DraftEvent (UpdateReplyTo target)
]
[ icon "share-alt" ]
, a
[ class reblogClasses
, ViewHelper.onClickWithPreventAndStop reblogEvent
]
[ icon "fire", text (toString status.reblogs_count) ]
, a
[ class favClasses
, ViewHelper.onClickWithPreventAndStop favEvent
]
[ icon "star", text (toString status.favourites_count) ]
]
statusEntryView : Mastodon.Status -> Html Msg statusEntryView : Mastodon.Status -> Html Msg
statusEntryView status = statusEntryView status =
let let
@ -227,7 +278,9 @@ statusEntryView status =
"" ""
in in
li [ class <| "list-group-item " ++ nsfwClass ] li [ class <| "list-group-item " ++ nsfwClass ]
[ statusView status ] [ statusView status
, statusActionsView status
]
timelineView : List Mastodon.Status -> String -> String -> Html Msg timelineView : List Mastodon.Status -> String -> String -> Html Msg
@ -264,6 +317,7 @@ notificationStatusView status { type_, account } =
_ -> _ ->
text "" text ""
, statusView status , statusView status
, statusActionsView status
] ]
@ -299,6 +353,29 @@ notificationListView notifications =
] ]
draftReplyToView : Draft -> Html Msg
draftReplyToView draft =
case draft.in_reply_to of
Just status ->
div [ class "in-reply-to" ]
[ p []
[ strong []
[ text "In reply to this toot ("
, a
[ href ""
, ViewHelper.onClickWithPreventAndStop <| DraftEvent ClearReplyTo
]
[ icon "remove" ]
, text ")"
]
]
, div [ class "well" ] [ statusView status ]
]
Nothing ->
text ""
draftView : Model -> Html Msg draftView : Model -> Html Msg
draftView { draft } = draftView { draft } =
let let
@ -310,9 +387,17 @@ draftView { draft } =
[ text <| visibility ++ ": " ++ description ] [ text <| visibility ++ ": " ++ description ]
in in
div [ class "panel panel-default" ] div [ class "panel panel-default" ]
[ div [ class "panel-heading" ] [ icon "envelope", text "Post a message" ] [ div [ class "panel-heading" ]
[ icon "envelope"
, text <|
if draft.in_reply_to /= Nothing then
"Post a reply"
else
"Post a message"
]
, div [ class "panel-body" ] , div [ class "panel-body" ]
[ Html.form [ class "form", onSubmit SubmitDraft ] [ draftReplyToView draft
, Html.form [ class "form", onSubmit SubmitDraft ]
[ div [ class "form-group checkbox" ] [ div [ class "form-group checkbox" ]
[ label [] [ label []
[ input [ input
@ -387,8 +472,17 @@ draftView { draft } =
, text " This post is NSFW" , text " This post is NSFW"
] ]
] ]
, p [ class "text-right" ] , justifiedButtonGroup
[ button [ class "btn btn-primary" ] [ button
[ type_ "button"
, class "btn btn-default"
, onClick (DraftEvent ClearDraft)
]
[ text "Clear" ]
, button
[ type_ "submit"
, class "btn btn-primary"
]
[ text "Toot!" ] [ text "Toot!" ]
] ]
] ]

View File

@ -34,7 +34,6 @@ onClickWithPreventAndStop msg =
formatContent : String -> List Mastodon.Mention -> List (Html Msg) formatContent : String -> List Mastodon.Mention -> List (Html Msg)
formatContent content mentions = formatContent content mentions =
content content
|> replace "&apos;" "'"
|> replace " ?" "&nbsp;?" |> replace " ?" "&nbsp;?"
|> replace " !" "&nbsp;!" |> replace " !" "&nbsp;!"
|> replace " :" "&nbsp;:" |> replace " :" "&nbsp;:"