diff --git a/public/index.html b/public/index.html index f731b96..97ffbe5 100644 --- a/public/index.html +++ b/public/index.html @@ -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); + }); diff --git a/public/style.css b/public/style.css index 2ecdd36..1b26388 100644 --- a/public/style.css +++ b/public/style.css @@ -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 { diff --git a/src/Command.elm b/src/Command.elm index 3f458c4..3859962 100644 --- a/src/Command.elm +++ b/src/Command.elm @@ -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) diff --git a/src/Mastodon/ApiUrl.elm b/src/Mastodon/ApiUrl.elm index 0536970..af4f13f 100644 --- a/src/Mastodon/ApiUrl.elm +++ b/src/Mastodon/ApiUrl.elm @@ -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" diff --git a/src/Mastodon/Decoder.elm b/src/Mastodon/Decoder.elm index febdeb4..f07ab99 100644 --- a/src/Mastodon/Decoder.elm +++ b/src/Mastodon/Decoder.elm @@ -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) diff --git a/src/Mastodon/Encoder.elm b/src/Mastodon/Encoder.elm index aebf933..6615dab 100644 --- a/src/Mastodon/Encoder.elm +++ b/src/Mastodon/Encoder.elm @@ -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) ) ] diff --git a/src/Mastodon/Model.elm b/src/Mastodon/Model.elm index 15310eb..f12e1c8 100644 --- a/src/Mastodon/Model.elm +++ b/src/Mastodon/Model.elm @@ -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 } diff --git a/src/Ports.elm b/src/Ports.elm index c80758b..99865f2 100644 --- a/src/Ports.elm +++ b/src/Ports.elm @@ -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 diff --git a/src/Subscription.elm b/src/Subscription.elm index 6afea84..bc70101 100644 --- a/src/Subscription.elm +++ b/src/Subscription.elm @@ -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 + ] diff --git a/src/Types.elm b/src/Types.elm index 2486b7e..750c16d 100644 --- a/src/Types.elm +++ b/src/Types.elm @@ -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 diff --git a/src/Update/Draft.elm b/src/Update/Draft.elm index c12a94c..45a51ec 100644 --- a/src/Update/Draft.elm +++ b/src/Update/Draft.elm @@ -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 } ! [] diff --git a/src/Update/Main.elm b/src/Update/Main.elm index c8c2d01..62957f4 100644 --- a/src/Update/Main.elm +++ b/src/Update/Main.elm @@ -25,6 +25,7 @@ toStatusRequestBody draft = , spoiler_text = draft.spoilerText , sensitive = draft.sensitive , visibility = draft.visibility + , media_ids = List.map .id draft.attachments } diff --git a/src/View/Draft.elm b/src/View/Draft.elm index 7f5bcf3..cc78775 100644 --- a/src/View/Draft.elm +++ b/src/View/Draft.elm @@ -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 "" + ]