Fix #78: Handle media upload. (#156)

This commit is contained in:
Nicolas Perriault 2017-05-11 10:55:15 +02:00 committed by GitHub
parent 2fae98f452
commit b7001eb8da
13 changed files with 401 additions and 193 deletions

View File

@ -64,6 +64,19 @@
});
});
app.ports.uploadMedia.subscribe(data => {
const files = Array.from(document.getElementById(data.id).files);
const formData = new FormData();
formData.append("file", files[0]);
fetch(data.url, {
method: "POST",
headers: { Authorization: "Bearer " + data.token },
body: formData,
})
.catch(err => app.ports.uploadError.send(err.message))
.then(response => response.text())
.then(app.ports.uploadSuccess.send);
});
</script>
</body>
</html>

View File

@ -410,6 +410,68 @@ li.load-more {
margin-left: 6px;
}
.draft-attachments-field {
margin-top: 12px;
}
.draft-attachments {
display: table;
width: 100%;
padding: 0 2px;
margin-bottom: 0px;
list-style-type: none;
}
.draft-attachment-entry {
display: table-cell;
width: 25%;
height: 60px;
}
.draft-attachment-entry a {
display: block;
height: 60px;
opacity: 0;
background: transparent;
font-size: 45px;
color: #fff;
text-align: center;
}
.draft-attachment-entry a:hover {
transition: 150ms all ease;
opacity: 1;
background: rgba(0, 0, 0, 0.85);
}
.draft-attachment-input {
display: block;
position: relative;
width: 100%;
cursor: pointer;
height: 35px;
outline: 0;
}
.draft-attachment-input:after {
background-image: linear-gradient(#484e55, #3a3f44 60%, #313539);
color: #c8c8c8;
line-height: 32px;
text-align: center;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: block;
content: 'Attach a media';
outline: 0;
}
.draft-attachment-input:hover:after {
background-image: linear-gradient(#020202, #101112 40%, #141618);
}
/* Status text content rules */
.attachment, .hashtag, .ellipsis {

View File

@ -27,6 +27,7 @@ module Command
, unfavouriteStatus
, follow
, unfollow
, uploadMedia
, focusId
, scrollColumnToTop
, scrollColumnToBottom
@ -479,6 +480,20 @@ unfollow client id =
Cmd.none
uploadMedia : Maybe Client -> String -> Cmd Msg
uploadMedia client fileInputId =
case client of
Just { server, token } ->
Ports.uploadMedia
{ id = fileInputId
, url = server ++ ApiUrl.uploadMedia
, token = token
}
Nothing ->
Cmd.none
focusId : String -> Cmd Msg
focusId id =
Dom.focus id |> Task.attempt (always NoOp)

View File

@ -21,6 +21,7 @@ module Mastodon.ApiUrl
, unfavourite
, follow
, unfollow
, uploadMedia
, streaming
, searchAccount
)
@ -149,3 +150,8 @@ unfavourite id =
streaming : String
streaming =
apiPrefix ++ "/streaming/"
uploadMedia : String
uploadMedia =
apiPrefix ++ "/media"

View File

@ -65,7 +65,7 @@ attachmentDecoder =
|> Pipe.required "id" Decode.int
|> Pipe.required "type" Decode.string
|> Pipe.required "url" Decode.string
|> Pipe.required "remote_url" Decode.string
|> Pipe.optional "remote_url" Decode.string ""
|> Pipe.required "preview_url" Decode.string
|> Pipe.required "text_url" (Decode.nullable Decode.string)

View File

@ -99,4 +99,5 @@ statusRequestBodyEncoder statusData =
, ( "spoiler_text", encodeMaybe Encode.string statusData.spoiler_text )
, ( "sensitive", Encode.bool statusData.sensitive )
, ( "visibility", Encode.string statusData.visibility )
, ( "media_ids", Encode.list (List.map Encode.int statusData.media_ids) )
]

View File

@ -190,12 +190,12 @@ type alias StatusRequestBody =
-- sensitive: set this to mark the media of the status as NSFW
-- spoiler_text: text to be shown as a warning before the actual content
-- visibility: either "direct", "private", "unlisted" or "public"
-- TODO: media_ids: array of media IDs to attach to the status (maximum 4)
{ status : String
, in_reply_to_id : Maybe Int
, spoiler_text : Maybe String
, sensitive : Bool
, visibility : String
, media_ids : List Int
}

View File

@ -5,8 +5,13 @@ port module Ports
, scrollIntoView
, saveClients
, setStatus
, uploadMedia
, uploadSuccess
, uploadError
)
-- Outgoing ports
port saveRegistration : String -> Cmd msg
@ -21,3 +26,16 @@ port setStatus : { id : String, status : String } -> Cmd msg
port scrollIntoView : String -> Cmd msg
port uploadMedia : { id : String, url : String, token : String } -> Cmd msg
-- Incoming ports
port uploadError : (String -> msg) -> Sub msg
port uploadSuccess : (String -> msg) -> Sub msg

View File

@ -2,6 +2,7 @@ module Subscription exposing (subscriptions)
import Autocomplete
import Mastodon.WebSocket
import Ports
import Time
import Types exposing (..)
@ -37,6 +38,18 @@ subscriptions { clients, currentView } =
autoCompleteSub =
Sub.map (DraftEvent << SetAutoState) Autocomplete.subscription
uploadSuccessSub =
Ports.uploadSuccess (DraftEvent << UploadResult)
uploadErrorSub =
Ports.uploadError (DraftEvent << UploadError)
in
[ timeSub, userWsSub, otherWsSub, autoCompleteSub ]
|> Sub.batch
Sub.batch
[ timeSub
, userWsSub
, otherWsSub
, autoCompleteSub
, uploadSuccessSub
, uploadErrorSub
]

View File

@ -15,16 +15,20 @@ type alias Flags =
type DraftMsg
= ClearDraft
| CloseAutocomplete
| RemoveMedia Int
| ResetAutocomplete Bool
| SelectAccount String
| SetAutoState Autocomplete.Msg
| ToggleSpoiler Bool
| UpdateInputInformation InputInformation
| UpdateSensitive Bool
| UpdateSpoiler String
| UpdateVisibility String
| UpdateReplyTo Status
| SelectAccount String
| ToggleSpoiler Bool
| UpdateInputInformation InputInformation
| ResetAutocomplete Bool
| CloseAutocomplete
| SetAutoState Autocomplete.Msg
| UploadError String
| UploadMedia String
| UploadResult String
type ViewerMsg
@ -120,6 +124,7 @@ type alias Draft =
, spoilerText : Maybe String
, sensitive : Bool
, visibility : String
, attachments : List Attachment
, statusLength : Int
-- Autocomplete values

View File

@ -7,9 +7,12 @@ module Update.Draft
import Autocomplete
import Command
import Json.Decode as Decode
import Mastodon.Decoder exposing (attachmentDecoder)
import Mastodon.Helper
import Mastodon.Model exposing (..)
import String.Extra
import Update.Error exposing (addErrorNotification)
import Types exposing (..)
import Util
@ -42,6 +45,7 @@ empty =
, spoilerText = Nothing
, sensitive = False
, visibility = "public"
, attachments = []
, statusLength = 0
, autoState = Autocomplete.empty
, autoAtPosition = Nothing
@ -70,202 +74,233 @@ showAutoMenu accounts atPosition query =
update : DraftMsg -> Account -> Model -> ( Model, Cmd Msg )
update draftMsg currentUser model =
let
draft =
model.draft
in
case draftMsg of
ClearDraft ->
{ model | draft = empty }
! [ Command.updateDomStatus empty.status ]
update draftMsg currentUser ({ draft } as model) =
case draftMsg of
ClearDraft ->
{ model | draft = empty }
! [ Command.updateDomStatus empty.status ]
ToggleSpoiler enabled ->
let
newDraft =
{ draft
| spoilerText =
if enabled then
Just ""
else
Nothing
}
in
{ model | draft = newDraft } ! []
UpdateSensitive sensitive ->
{ model | draft = { draft | sensitive = sensitive } } ! []
UpdateSpoiler spoilerText ->
{ model | draft = { draft | spoilerText = Just spoilerText } } ! []
UpdateVisibility visibility ->
{ model | draft = { draft | visibility = visibility } } ! []
UpdateReplyTo status ->
let
newStatus =
Mastodon.Helper.getReplyPrefix currentUser status
in
{ model
| draft =
{ draft
| inReplyTo = Just status
, status = newStatus
, sensitive = Maybe.withDefault False status.sensitive
, spoilerText =
if status.spoiler_text == "" then
Nothing
else
Just status.spoiler_text
, visibility = status.visibility
}
}
! [ Command.focusId "status"
, Command.updateDomStatus newStatus
]
UpdateInputInformation { status, selectionStart } ->
let
stringToPos =
String.slice 0 selectionStart status
atPosition =
case (String.right 1 stringToPos) of
"@" ->
Just selectionStart
" " ->
ToggleSpoiler enabled ->
let
newDraft =
{ draft
| spoilerText =
if enabled then
Just ""
else
Nothing
}
in
{ model | draft = newDraft } ! []
_ ->
model.draft.autoAtPosition
UpdateSensitive sensitive ->
{ model | draft = { draft | sensitive = sensitive } } ! []
query =
case atPosition of
Just position ->
String.slice position (String.length stringToPos) stringToPos
UpdateSpoiler spoilerText ->
{ model | draft = { draft | spoilerText = Just spoilerText } } ! []
Nothing ->
""
UpdateVisibility visibility ->
{ model | draft = { draft | visibility = visibility } } ! []
newDraft =
UpdateReplyTo status ->
let
newStatus =
Mastodon.Helper.getReplyPrefix currentUser status
in
{ model
| draft =
{ draft
| status = status
, statusLength = String.length status
, autoCursorPosition = selectionStart
, autoAtPosition = atPosition
, autoQuery = query
, showAutoMenu =
showAutoMenu
draft.autoAccounts
draft.autoAtPosition
draft.autoQuery
| inReplyTo = Just status
, status = newStatus
, sensitive = Maybe.withDefault False status.sensitive
, spoilerText =
if status.spoiler_text == "" then
Nothing
else
Just status.spoiler_text
, visibility = status.visibility
}
in
{ model | draft = newDraft }
! if query /= "" && atPosition /= Nothing then
[ Command.searchAccounts (List.head model.clients) query model.draft.autoMaxResults False ]
else
[]
}
! [ Command.focusId "status"
, Command.updateDomStatus newStatus
]
SelectAccount id ->
let
account =
List.filter (\account -> toString account.id == id) draft.autoAccounts
|> List.head
UpdateInputInformation { status, selectionStart } ->
let
stringToPos =
String.slice 0 selectionStart status
stringToAtPos =
case draft.autoAtPosition of
Just atPosition ->
String.slice 0 atPosition draft.status
atPosition =
case (String.right 1 stringToPos) of
"@" ->
Just selectionStart
_ ->
""
stringToPos =
String.slice 0 draft.autoCursorPosition draft.status
newStatus =
case draft.autoAtPosition of
Just atPosition ->
String.Extra.replaceSlice
(case account of
Just a ->
a.acct ++ " "
Nothing ->
""
)
atPosition
((String.length draft.autoQuery) + atPosition)
draft.status
_ ->
""
newDraft =
{ draft
| status = newStatus
, autoAtPosition = Nothing
, autoQuery = ""
, autoState = Autocomplete.empty
, autoAccounts = []
, showAutoMenu = False
}
in
{ model | draft = newDraft }
-- As we are using defaultValue, we need to update the textarea
-- using a port.
! [ Command.updateDomStatus newStatus ]
SetAutoState autoMsg ->
let
( newState, maybeMsg ) =
Autocomplete.update
autocompleteUpdateConfig
autoMsg
draft.autoMaxResults
draft.autoState
(Util.acceptableAccounts draft.autoQuery draft.autoAccounts)
newModel =
{ model | draft = { draft | autoState = newState } }
in
case maybeMsg of
Just (DraftEvent updateMsg) ->
update updateMsg currentUser newModel
" " ->
Nothing
_ ->
newModel ! []
model.draft.autoAtPosition
CloseAutocomplete ->
let
newDraft =
{ draft
| showAutoMenu = False
, autoState = Autocomplete.reset autocompleteUpdateConfig draft.autoState
}
in
{ model | draft = newDraft } ! []
query =
case atPosition of
Just position ->
String.slice position (String.length stringToPos) stringToPos
ResetAutocomplete toTop ->
Nothing ->
""
newDraft =
{ draft
| status = status
, statusLength = String.length status
, autoCursorPosition = selectionStart
, autoAtPosition = atPosition
, autoQuery = query
, showAutoMenu =
showAutoMenu
draft.autoAccounts
draft.autoAtPosition
draft.autoQuery
}
in
{ model | draft = newDraft }
! if query /= "" && atPosition /= Nothing then
[ Command.searchAccounts (List.head model.clients) query model.draft.autoMaxResults False ]
else
[]
SelectAccount id ->
let
account =
List.filter (\account -> toString account.id == id) draft.autoAccounts
|> List.head
stringToAtPos =
case draft.autoAtPosition of
Just atPosition ->
String.slice 0 atPosition draft.status
_ ->
""
stringToPos =
String.slice 0 draft.autoCursorPosition draft.status
newStatus =
case draft.autoAtPosition of
Just atPosition ->
String.Extra.replaceSlice
(case account of
Just a ->
a.acct ++ " "
Nothing ->
""
)
atPosition
((String.length draft.autoQuery) + atPosition)
draft.status
_ ->
""
newDraft =
{ draft
| status = newStatus
, autoAtPosition = Nothing
, autoQuery = ""
, autoState = Autocomplete.empty
, autoAccounts = []
, showAutoMenu = False
}
in
{ model | draft = newDraft }
-- As we are using defaultValue, we need to update the textarea
-- using a port.
! [ Command.updateDomStatus newStatus ]
SetAutoState autoMsg ->
let
( newState, maybeMsg ) =
Autocomplete.update
autocompleteUpdateConfig
autoMsg
draft.autoMaxResults
draft.autoState
(Util.acceptableAccounts draft.autoQuery draft.autoAccounts)
newModel =
{ model | draft = { draft | autoState = newState } }
in
case maybeMsg of
Just (DraftEvent updateMsg) ->
update updateMsg currentUser newModel
_ ->
newModel ! []
CloseAutocomplete ->
let
newDraft =
{ draft
| showAutoMenu = False
, autoState = Autocomplete.reset autocompleteUpdateConfig draft.autoState
}
in
{ model | draft = newDraft } ! []
ResetAutocomplete toTop ->
let
newDraft =
{ draft
| autoState =
if toTop then
Autocomplete.resetToFirstItem
autocompleteUpdateConfig
(Util.acceptableAccounts draft.autoQuery draft.autoAccounts)
draft.autoMaxResults
draft.autoState
else
Autocomplete.resetToLastItem
autocompleteUpdateConfig
(Util.acceptableAccounts draft.autoQuery draft.autoAccounts)
draft.autoMaxResults
draft.autoState
}
in
{ model | draft = newDraft } ! []
RemoveMedia id ->
let
newDraft =
{ draft | attachments = List.filter (\a -> a.id /= id) draft.attachments }
in
{ model | draft = newDraft } ! []
UploadMedia id ->
model ! [ Command.uploadMedia (List.head model.clients) id ]
UploadError error ->
{ model | errors = addErrorNotification error model } ! []
UploadResult encoded ->
if encoded == "" then
-- user has likely pressed "Cancel" in the file input dialog
model ! []
else
let
newDraft =
{ draft
| autoState =
if toTop then
Autocomplete.resetToFirstItem
autocompleteUpdateConfig
(Util.acceptableAccounts draft.autoQuery draft.autoAccounts)
draft.autoMaxResults
draft.autoState
else
Autocomplete.resetToLastItem
autocompleteUpdateConfig
(Util.acceptableAccounts draft.autoQuery draft.autoAccounts)
draft.autoMaxResults
draft.autoState
}
decodedAttachment =
Decode.decodeString attachmentDecoder encoded
in
{ model | draft = newDraft } ! []
case decodedAttachment of
Ok attachment ->
{ model
| draft =
{ draft
| attachments = List.append draft.attachments [ attachment ]
}
}
! []
Err error ->
{ model | errors = addErrorNotification error model } ! []

View File

@ -25,6 +25,7 @@ toStatusRequestBody draft =
, spoiler_text = draft.spoilerText
, sensitive = draft.sensitive
, visibility = draft.visibility
, media_ids = List.map .id draft.attachments
}

View File

@ -239,7 +239,7 @@ draftView ({ draft, currentUser } as model) =
textarea
[ id "status"
, class "form-control"
, rows 8
, rows 7
, placeholder <|
if hasSpoiler then
"This text will be hidden by default, as you have enabled a spoiler."
@ -255,6 +255,7 @@ draftView ({ draft, currentUser } as model) =
, autoMenu
]
, visibilitySelector draft
, fileUploadField draft
, div [ class "form-group checkbox" ]
[ label []
[ input
@ -291,3 +292,41 @@ draftView ({ draft, currentUser } as model) =
]
]
]
fileUploadField : Draft -> Html Msg
fileUploadField draft =
let
attachmentPreview attachment =
li
[ class "draft-attachment-entry"
, style
[ ( "background"
, "url(" ++ attachment.preview_url ++ ") center center / cover no-repeat"
)
]
]
[ a
[ href ""
, onClickWithPreventAndStop <| DraftEvent (RemoveMedia attachment.id)
]
[ text "×" ]
]
in
div [ class "draft-attachments-field form-group" ]
[ if List.length draft.attachments > 0 then
ul [ class "draft-attachments" ] <|
List.map attachmentPreview draft.attachments
else
text ""
, if List.length draft.attachments < 4 then
input
[ type_ "file"
, id "draft-attachment"
, class "form-control draft-attachment-input"
, on "change" (Decode.succeed <| DraftEvent (UploadMedia "draft-attachment"))
]
[]
else
text ""
]