1
0
Fork 0

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> </script>
</body> </body>
</html> </html>

View File

@ -410,6 +410,68 @@ li.load-more {
margin-left: 6px; 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 */ /* Status text content rules */
.attachment, .hashtag, .ellipsis { .attachment, .hashtag, .ellipsis {

View File

@ -27,6 +27,7 @@ module Command
, unfavouriteStatus , unfavouriteStatus
, follow , follow
, unfollow , unfollow
, uploadMedia
, focusId , focusId
, scrollColumnToTop , scrollColumnToTop
, scrollColumnToBottom , scrollColumnToBottom
@ -479,6 +480,20 @@ unfollow client id =
Cmd.none 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 : String -> Cmd Msg
focusId id = focusId id =
Dom.focus id |> Task.attempt (always NoOp) Dom.focus id |> Task.attempt (always NoOp)

View File

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

View File

@ -65,7 +65,7 @@ attachmentDecoder =
|> Pipe.required "id" Decode.int |> Pipe.required "id" Decode.int
|> Pipe.required "type" Decode.string |> Pipe.required "type" Decode.string
|> Pipe.required "url" 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 "preview_url" Decode.string
|> Pipe.required "text_url" (Decode.nullable 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 ) , ( "spoiler_text", encodeMaybe Encode.string statusData.spoiler_text )
, ( "sensitive", Encode.bool statusData.sensitive ) , ( "sensitive", Encode.bool statusData.sensitive )
, ( "visibility", Encode.string statusData.visibility ) , ( "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 -- 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 -- spoiler_text: text to be shown as a warning before the actual content
-- visibility: either "direct", "private", "unlisted" or "public" -- visibility: either "direct", "private", "unlisted" or "public"
-- TODO: media_ids: array of media IDs to attach to the status (maximum 4)
{ status : String { status : String
, in_reply_to_id : Maybe Int , in_reply_to_id : Maybe Int
, spoiler_text : Maybe String , spoiler_text : Maybe String
, sensitive : Bool , sensitive : Bool
, visibility : String , visibility : String
, media_ids : List Int
} }

View File

@ -5,8 +5,13 @@ port module Ports
, scrollIntoView , scrollIntoView
, saveClients , saveClients
, setStatus , setStatus
, uploadMedia
, uploadSuccess
, uploadError
) )
-- Outgoing ports
port saveRegistration : String -> Cmd msg port saveRegistration : String -> Cmd msg
@ -21,3 +26,16 @@ port setStatus : { id : String, status : String } -> Cmd msg
port scrollIntoView : 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 Autocomplete
import Mastodon.WebSocket import Mastodon.WebSocket
import Ports
import Time import Time
import Types exposing (..) import Types exposing (..)
@ -37,6 +38,18 @@ subscriptions { clients, currentView } =
autoCompleteSub = autoCompleteSub =
Sub.map (DraftEvent << SetAutoState) Autocomplete.subscription Sub.map (DraftEvent << SetAutoState) Autocomplete.subscription
uploadSuccessSub =
Ports.uploadSuccess (DraftEvent << UploadResult)
uploadErrorSub =
Ports.uploadError (DraftEvent << UploadError)
in 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 type DraftMsg
= ClearDraft = ClearDraft
| CloseAutocomplete
| RemoveMedia Int
| ResetAutocomplete Bool
| SelectAccount String
| SetAutoState Autocomplete.Msg
| ToggleSpoiler Bool
| UpdateInputInformation InputInformation
| UpdateSensitive Bool | UpdateSensitive Bool
| UpdateSpoiler String | UpdateSpoiler String
| UpdateVisibility String | UpdateVisibility String
| UpdateReplyTo Status | UpdateReplyTo Status
| SelectAccount String | UploadError String
| ToggleSpoiler Bool | UploadMedia String
| UpdateInputInformation InputInformation | UploadResult String
| ResetAutocomplete Bool
| CloseAutocomplete
| SetAutoState Autocomplete.Msg
type ViewerMsg type ViewerMsg
@ -120,6 +124,7 @@ type alias Draft =
, spoilerText : Maybe String , spoilerText : Maybe String
, sensitive : Bool , sensitive : Bool
, visibility : String , visibility : String
, attachments : List Attachment
, statusLength : Int , statusLength : Int
-- Autocomplete values -- Autocomplete values

View File

@ -7,9 +7,12 @@ module Update.Draft
import Autocomplete import Autocomplete
import Command import Command
import Json.Decode as Decode
import Mastodon.Decoder exposing (attachmentDecoder)
import Mastodon.Helper import Mastodon.Helper
import Mastodon.Model exposing (..) import Mastodon.Model exposing (..)
import String.Extra import String.Extra
import Update.Error exposing (addErrorNotification)
import Types exposing (..) import Types exposing (..)
import Util import Util
@ -42,6 +45,7 @@ empty =
, spoilerText = Nothing , spoilerText = Nothing
, sensitive = False , sensitive = False
, visibility = "public" , visibility = "public"
, attachments = []
, statusLength = 0 , statusLength = 0
, autoState = Autocomplete.empty , autoState = Autocomplete.empty
, autoAtPosition = Nothing , autoAtPosition = Nothing
@ -70,202 +74,233 @@ showAutoMenu accounts atPosition query =
update : DraftMsg -> Account -> Model -> ( Model, Cmd Msg ) update : DraftMsg -> Account -> Model -> ( Model, Cmd Msg )
update draftMsg currentUser model = update draftMsg currentUser ({ draft } as model) =
let case draftMsg of
draft = ClearDraft ->
model.draft { model | draft = empty }
in ! [ Command.updateDomStatus empty.status ]
case draftMsg of
ClearDraft ->
{ model | draft = empty }
! [ Command.updateDomStatus empty.status ]
ToggleSpoiler enabled -> ToggleSpoiler enabled ->
let let
newDraft = newDraft =
{ draft { draft
| spoilerText = | spoilerText =
if enabled then if enabled then
Just "" Just ""
else 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
" " ->
Nothing Nothing
}
in
{ model | draft = newDraft } ! []
_ -> UpdateSensitive sensitive ->
model.draft.autoAtPosition { model | draft = { draft | sensitive = sensitive } } ! []
query = UpdateSpoiler spoilerText ->
case atPosition of { model | draft = { draft | spoilerText = Just spoilerText } } ! []
Just position ->
String.slice position (String.length stringToPos) stringToPos
Nothing -> UpdateVisibility visibility ->
"" { model | draft = { draft | visibility = visibility } } ! []
newDraft = UpdateReplyTo status ->
let
newStatus =
Mastodon.Helper.getReplyPrefix currentUser status
in
{ model
| draft =
{ draft { draft
| status = status | inReplyTo = Just status
, statusLength = String.length status , status = newStatus
, autoCursorPosition = selectionStart , sensitive = Maybe.withDefault False status.sensitive
, autoAtPosition = atPosition , spoilerText =
, autoQuery = query if status.spoiler_text == "" then
, showAutoMenu = Nothing
showAutoMenu else
draft.autoAccounts Just status.spoiler_text
draft.autoAtPosition , visibility = status.visibility
draft.autoQuery
} }
in }
{ model | draft = newDraft } ! [ Command.focusId "status"
! if query /= "" && atPosition /= Nothing then , Command.updateDomStatus newStatus
[ Command.searchAccounts (List.head model.clients) query model.draft.autoMaxResults False ] ]
else
[]
SelectAccount id -> UpdateInputInformation { status, selectionStart } ->
let let
account = stringToPos =
List.filter (\account -> toString account.id == id) draft.autoAccounts String.slice 0 selectionStart status
|> List.head
stringToAtPos = atPosition =
case draft.autoAtPosition of case (String.right 1 stringToPos) of
Just atPosition -> "@" ->
String.slice 0 atPosition draft.status Just selectionStart
_ -> " " ->
"" Nothing
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 ! [] model.draft.autoAtPosition
CloseAutocomplete -> query =
let case atPosition of
newDraft = Just position ->
{ draft String.slice position (String.length stringToPos) stringToPos
| showAutoMenu = False
, autoState = Autocomplete.reset autocompleteUpdateConfig draft.autoState
}
in
{ model | draft = newDraft } ! []
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 let
newDraft = decodedAttachment =
{ draft Decode.decodeString attachmentDecoder encoded
| 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 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 , spoiler_text = draft.spoilerText
, sensitive = draft.sensitive , sensitive = draft.sensitive
, visibility = draft.visibility , visibility = draft.visibility
, media_ids = List.map .id draft.attachments
} }

View File

@ -239,7 +239,7 @@ draftView ({ draft, currentUser } as model) =
textarea textarea
[ id "status" [ id "status"
, class "form-control" , class "form-control"
, rows 8 , rows 7
, placeholder <| , placeholder <|
if hasSpoiler then if hasSpoiler then
"This text will be hidden by default, as you have enabled a spoiler." "This text will be hidden by default, as you have enabled a spoiler."
@ -255,6 +255,7 @@ draftView ({ draft, currentUser } as model) =
, autoMenu , autoMenu
] ]
, visibilitySelector draft , visibilitySelector draft
, fileUploadField draft
, div [ class "form-group checkbox" ] , div [ class "form-group checkbox" ]
[ label [] [ label []
[ input [ 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 ""
]