diff --git a/public/style.css b/public/style.css index c5a752e..9236780 100644 --- a/public/style.css +++ b/public/style.css @@ -100,21 +100,39 @@ body { } .follow-entry { - min-height: 38px; + display: flex; + flex-direction: row; + flex-wrap: nowrap; + justify-content: space-between; + align-content: stretch; + align-items: flex-start; } .follow-entry .avatar { + order: 0; + flex: 0 1 auto; + align-self: auto; width: 38px; height: 38px; + margin-right: 10px; } -.follow-entry .username { - font-weight: normal; - font-size: 97%; - margin-left: 50px; + +.follow-entry .userinfo { + order: 0; + flex: 10 1 auto; + align-self: auto; overflow: hidden; text-overflow: ellipsis; } +.follow-entry button { + order: 0; + flex: 0 1 auto; + align-self: auto; + width: 40px; + height: 40px; +} + .acct { font-size: 97%; font-weight: normal; @@ -317,8 +335,10 @@ body { /* Account rules */ .account-detail { - text-align:center; + position: relative; + text-align: center; } + .account-detail .opacity-layer{ background: rgba(49,53,67,0.9); } @@ -330,6 +350,14 @@ body { margin:0 auto 0; } +.account-detail .btn { + position: absolute; + top: 1em; + left: 1em; + width: 50px; + height: 50px; + opacity: .8; +} .account-detail .account-display-name { display: block; diff --git a/src/Command.elm b/src/Command.elm index 20ef06d..aa6a059 100644 --- a/src/Command.elm +++ b/src/Command.elm @@ -8,7 +8,10 @@ module Command , loadNotifications , loadUserAccount , loadAccount - , loadAccountInfo + , loadAccountTimeline + , loadAccountFollowers + , loadAccountFollowing + , loadRelationships , loadThread , loadTimelines , postStatus @@ -17,6 +20,8 @@ module Command , unreblogStatus , favouriteStatus , unfavouriteStatus + , follow + , unfollow ) import Json.Encode as Encode @@ -112,25 +117,56 @@ loadAccount : Maybe Client -> Int -> Cmd Msg loadAccount client accountId = case client of Just client -> - Mastodon.Http.fetchAccount client accountId - |> Mastodon.Http.send (MastodonEvent << AccountReceived) + Cmd.batch + [ Mastodon.Http.fetchAccount client accountId + |> Mastodon.Http.send (MastodonEvent << AccountReceived) + , Mastodon.Http.fetchRelationships client [ accountId ] + |> Mastodon.Http.send (MastodonEvent << AccountRelationship) + ] Nothing -> Cmd.none -loadAccountInfo : Maybe Client -> Int -> Cmd Msg -loadAccountInfo client accountId = +loadAccountTimeline : Maybe Client -> Int -> Cmd Msg +loadAccountTimeline client accountId = case client of Just client -> - Cmd.batch - [ Mastodon.Http.fetchAccountTimeline client accountId - |> Mastodon.Http.send (MastodonEvent << AccountTimeline) - , Mastodon.Http.fetchAccountFollowers client accountId - |> Mastodon.Http.send (MastodonEvent << AccountFollowers) - , Mastodon.Http.fetchAccountFollowing client accountId - |> Mastodon.Http.send (MastodonEvent << AccountFollowing) - ] + Mastodon.Http.fetchAccountTimeline client accountId + |> Mastodon.Http.send (MastodonEvent << AccountTimeline) + + Nothing -> + Cmd.none + + +loadAccountFollowers : Maybe Client -> Int -> Cmd Msg +loadAccountFollowers client accountId = + case client of + Just client -> + Mastodon.Http.fetchAccountFollowers client accountId + |> Mastodon.Http.send (MastodonEvent << AccountFollowers) + + Nothing -> + Cmd.none + + +loadAccountFollowing : Maybe Client -> Int -> Cmd Msg +loadAccountFollowing client accountId = + case client of + Just client -> + Mastodon.Http.fetchAccountFollowing client accountId + |> Mastodon.Http.send (MastodonEvent << AccountFollowing) + + Nothing -> + Cmd.none + + +loadRelationships : Maybe Client -> List Int -> Cmd Msg +loadRelationships client accountIds = + case client of + Just client -> + Mastodon.Http.fetchRelationships client accountIds + |> Mastodon.Http.send (MastodonEvent << AccountRelationships) Nothing -> Cmd.none @@ -229,3 +265,25 @@ unfavouriteStatus client statusId = Nothing -> Cmd.none + + +follow : Maybe Client -> Int -> Cmd Msg +follow client id = + case client of + Just client -> + Mastodon.Http.follow client id + |> Mastodon.Http.send (MastodonEvent << AccountFollowed) + + Nothing -> + Cmd.none + + +unfollow : Maybe Client -> Int -> Cmd Msg +unfollow client id = + case client of + Just client -> + Mastodon.Http.unfollow client id + |> Mastodon.Http.send (MastodonEvent << AccountUnfollowed) + + Nothing -> + Cmd.none diff --git a/src/Mastodon/ApiUrl.elm b/src/Mastodon/ApiUrl.elm index 977b9f0..db3cec1 100644 --- a/src/Mastodon/ApiUrl.elm +++ b/src/Mastodon/ApiUrl.elm @@ -12,12 +12,15 @@ module Mastodon.ApiUrl , homeTimeline , publicTimeline , notifications + , relationships , statuses , context , reblog , unreblog , favourite , unfavourite + , follow + , unfollow , streaming ) @@ -51,11 +54,32 @@ account id = accounts ++ (toString id) +follow : Server -> Int -> String +follow server id = + server ++ accounts ++ (toString id) ++ "/follow" + + +unfollow : Server -> Int -> String +unfollow server id = + server ++ accounts ++ (toString id) ++ "/unfollow" + + userAccount : Server -> String userAccount server = server ++ accounts ++ "verify_credentials" +relationships : List Int -> String +relationships ids = + let + qs = + ids + |> List.map (\id -> "id[]=" ++ (toString id)) + |> String.join "&" + in + accounts ++ "relationships?" ++ qs + + followers : Int -> String followers id = (account id) ++ "/followers" diff --git a/src/Mastodon/Decoder.elm b/src/Mastodon/Decoder.elm index b4861bd..febdeb4 100644 --- a/src/Mastodon/Decoder.elm +++ b/src/Mastodon/Decoder.elm @@ -11,6 +11,7 @@ module Mastodon.Decoder , notificationDecoder , tagDecoder , reblogDecoder + , relationshipDecoder , statusDecoder , webSocketPayloadDecoder , webSocketEventDecoder @@ -100,6 +101,17 @@ notificationDecoder = |> Pipe.optional "status" (Decode.nullable statusDecoder) Nothing +relationshipDecoder : Decode.Decoder Relationship +relationshipDecoder = + Pipe.decode Relationship + |> Pipe.required "id" Decode.int + |> Pipe.required "blocking" Decode.bool + |> Pipe.required "followed_by" Decode.bool + |> Pipe.required "following" Decode.bool + |> Pipe.required "muting" Decode.bool + |> Pipe.required "requested" Decode.bool + + tagDecoder : Decode.Decoder Tag tagDecoder = Pipe.decode Tag diff --git a/src/Mastodon/Http.elm b/src/Mastodon/Http.elm index 8823d90..1aac13c 100644 --- a/src/Mastodon/Http.elm +++ b/src/Mastodon/Http.elm @@ -6,6 +6,8 @@ module Mastodon.Http , unreblog , favourite , unfavourite + , follow + , unfollow , register , getAuthorizationUrl , getAccessToken @@ -17,6 +19,7 @@ module Mastodon.Http , fetchNotifications , fetchGlobalTimeline , fetchUserTimeline + , fetchRelationships , postStatus , deleteStatus , userAccount @@ -116,6 +119,11 @@ fetchUserTimeline client = fetch client ApiUrl.homeTimeline <| Decode.list statusDecoder +fetchRelationships : Client -> List Int -> Request (List Relationship) +fetchRelationships client ids = + fetch client (ApiUrl.relationships ids) <| Decode.list relationshipDecoder + + fetchLocalTimeline : Client -> Request (List Status) fetchLocalTimeline client = fetch client (ApiUrl.publicTimeline (Just "public")) <| Decode.list statusDecoder @@ -201,3 +209,17 @@ unfavourite client id = HttpBuilder.post (ApiUrl.unfavourite client.server id) |> HttpBuilder.withHeader "Authorization" ("Bearer " ++ client.token) |> HttpBuilder.withExpect (Http.expectJson statusDecoder) + + +follow : Client -> Int -> Request Relationship +follow client id = + HttpBuilder.post (ApiUrl.follow client.server id) + |> HttpBuilder.withHeader "Authorization" ("Bearer " ++ client.token) + |> HttpBuilder.withExpect (Http.expectJson relationshipDecoder) + + +unfollow : Client -> Int -> Request Relationship +unfollow client id = + HttpBuilder.post (ApiUrl.unfollow client.server id) + |> HttpBuilder.withHeader "Authorization" ("Bearer " ++ client.token) + |> HttpBuilder.withExpect (Http.expectJson relationshipDecoder) diff --git a/src/Mastodon/Model.elm b/src/Mastodon/Model.elm index ea29a42..1db1618 100644 --- a/src/Mastodon/Model.elm +++ b/src/Mastodon/Model.elm @@ -11,6 +11,7 @@ module Mastodon.Model , Notification , NotificationAggregate , Reblog(..) + , Relationship , Tag , Status , StatusRequestBody @@ -148,6 +149,16 @@ type Reblog = Reblog Status +type alias Relationship = + { id : Int + , blocking : Bool + , followed_by : Bool + , following : Bool + , muting : Bool + , requested : Bool + } + + type alias Status = { account : Account , content : String diff --git a/src/Model.elm b/src/Model.elm index 8be3519..4ba0b3c 100644 --- a/src/Model.elm +++ b/src/Model.elm @@ -53,6 +53,8 @@ init flags location = , accountTimeline = [] , accountFollowers = [] , accountFollowing = [] + , accountRelationships = [] + , accountRelationship = Nothing , notifications = [] , draft = defaultDraft , errors = [] @@ -150,6 +152,38 @@ deleteStatusFromTimeline statusId timeline = ) +{-| Update viewed account relationships as well as the relationship with the +current connected user, both according to the "following" status provided. +-} +processFollowEvent : Relationship -> Bool -> Model -> Model +processFollowEvent relationship flag model = + let + updateRelationship r = + if r.id == relationship.id then + { r | following = flag } + else + r + + accountRelationships = + model.accountRelationships |> List.map updateRelationship + + accountRelationship = + case model.accountRelationship of + Just accountRelationship -> + if accountRelationship.id == relationship.id then + Just { relationship | following = flag } + else + model.accountRelationship + + Nothing -> + Nothing + in + { model + | accountRelationships = accountRelationships + , accountRelationship = accountRelationship + } + + updateDraft : DraftMsg -> Account -> Draft -> ( Draft, Cmd Msg ) updateDraft draftMsg currentUser draft = case draftMsg of @@ -222,6 +256,22 @@ processMastodonEvent msg model = Err error -> { model | errors = (errorText error) :: model.errors } ! [] + AccountFollowed result -> + case result of + Ok relationship -> + processFollowEvent relationship True model ! [] + + Err error -> + { model | errors = (errorText error) :: model.errors } ! [] + + AccountUnfollowed result -> + case result of + Ok relationship -> + processFollowEvent relationship False model ! [] + + Err error -> + { model | errors = (errorText error) :: model.errors } ! [] + AppRegistered result -> case result of Ok registration -> @@ -329,7 +379,7 @@ processMastodonEvent msg model = case result of Ok account -> { model | currentView = AccountView account } - ! [ Command.loadAccountInfo model.client account.id ] + ! [ Command.loadAccountTimeline model.client account.id ] Err error -> { model @@ -348,16 +398,37 @@ processMastodonEvent msg model = AccountFollowers result -> case result of - Ok statuses -> - { model | accountFollowers = statuses } ! [] + Ok followers -> + { model | accountFollowers = followers } + ! [ Command.loadRelationships model.client <| List.map .id followers ] Err error -> { model | errors = (errorText error) :: model.errors } ! [] AccountFollowing result -> case result of - Ok statuses -> - { model | accountFollowing = statuses } ! [] + Ok following -> + { model | accountFollowing = following } + ! [ Command.loadRelationships model.client <| List.map .id following ] + + Err error -> + { model | errors = (errorText error) :: model.errors } ! [] + + AccountRelationship result -> + case result of + Ok [ relationship ] -> + { model | accountRelationship = Just relationship } ! [] + + Ok _ -> + model ! [] + + Err error -> + { model | errors = (errorText error) :: model.errors } ! [] + + AccountRelationships result -> + case result of + Ok relationships -> + { model | accountRelationships = relationships } ! [] Err error -> { model | errors = (errorText error) :: model.errors } ! [] @@ -493,6 +564,12 @@ update msg model = CloseThread -> { model | currentView = preferredTimeline model } ! [] + FollowAccount id -> + model ! [ Command.follow model.client id ] + + UnfollowAccount id -> + model ! [ Command.unfollow model.client id ] + DeleteStatus id -> model ! [ Command.deleteStatus model.client id ] @@ -531,14 +608,22 @@ update msg model = model ! [ Command.postStatus model.client <| toStatusRequestBody model.draft ] LoadAccount accountId -> - { model | currentView = preferredTimeline model } + { model + | accountTimeline = [] + , accountFollowers = [] + , accountFollowing = [] + , accountRelationships = [] + , accountRelationship = Nothing + } ! [ Command.loadAccount model.client accountId ] ViewAccountFollowers account -> - { model | currentView = AccountFollowersView account model.accountFollowers } ! [] + { model | currentView = AccountFollowersView account model.accountFollowers } + ! [ Command.loadAccountFollowers model.client account.id ] ViewAccountFollowing account -> - { model | currentView = AccountFollowingView account model.accountFollowing } ! [] + { model | currentView = AccountFollowingView account model.accountFollowing } + ! [ Command.loadAccountFollowing model.client account.id ] ViewAccountStatuses account -> { model | currentView = AccountView account } ! [] diff --git a/src/Types.elm b/src/Types.elm index f0143ef..d5ce6b4 100644 --- a/src/Types.elm +++ b/src/Types.elm @@ -27,10 +27,14 @@ type ViewerMsg type MastodonMsg = AccessToken (Result Error AccessTokenResult) + | AccountFollowed (Result Error Relationship) | AccountFollowers (Result Error (List Account)) | AccountFollowing (Result Error (List Account)) | AccountReceived (Result Error Account) + | AccountRelationship (Result Error (List Relationship)) + | AccountRelationships (Result Error (List Relationship)) | AccountTimeline (Result Error (List Status)) + | AccountUnfollowed (Result Error Relationship) | AppRegistered (Result Error AppRegistration) | ContextLoaded Status (Result Error Context) | CurrentUser (Result Error Account) @@ -58,6 +62,7 @@ type Msg | CloseThread | DeleteStatus Int | DraftEvent DraftMsg + | FollowAccount Int | LoadAccount Int | MastodonEvent MastodonMsg | NoOp @@ -68,6 +73,7 @@ type Msg | ScrollColumn String | ServerChange String | SubmitDraft + | UnfollowAccount Int | UrlChange Navigation.Location | UseGlobalTimeline Bool | UnreblogStatus Int @@ -127,6 +133,8 @@ type alias Model = , accountTimeline : List Status , accountFollowers : List Account , accountFollowing : List Account + , accountRelationships : List Relationship + , accountRelationship : Maybe Relationship , notifications : List NotificationAggregate , draft : Draft , errors : List String diff --git a/src/View.elm b/src/View.elm index f3f4d81..6ba47e8 100644 --- a/src/View.elm +++ b/src/View.elm @@ -4,7 +4,7 @@ import Dict import Html exposing (..) import Html.Attributes exposing (..) import Html.Events exposing (..) -import List.Extra exposing (elemIndex, getAt) +import List.Extra exposing (find, elemIndex, getAt) import Mastodon.Helper import Mastodon.Model exposing (..) import Types exposing (..) @@ -14,6 +14,14 @@ import Date.Extra.Config.Config_en_au as DateEn import Date.Extra.Format as DateFormat +type alias CurrentUser = + Account + + +type alias CurrentUserRelation = + Maybe Relationship + + visibilities : Dict.Dict String String visibilities = Dict.fromList @@ -202,21 +210,60 @@ statusView context ({ account, content, media_attachments, reblog, mentions } as ] -followView : Account -> Html Msg -followView account = +followButton : CurrentUser -> CurrentUserRelation -> Account -> Html Msg +followButton currentUser relationship account = + if Mastodon.Helper.sameAccount account currentUser then + text "" + else + let + ( followEvent, btnClasses, iconName, tooltip ) = + case relationship of + Nothing -> + ( NoOp + , "btn btn-default btn-disabled" + , "question-sign" + , "Unknown relationship" + ) + + Just relationship -> + if relationship.following then + ( UnfollowAccount account.id + , "btn btn-default btn-primary" + , "eye-close" + , "Unfollow" + ) + else + ( FollowAccount account.id + , "btn btn-default" + , "eye-open" + , "Follow" + ) + in + button [ class btnClasses, title tooltip, onClick followEvent ] + [ icon iconName ] + + +followView : CurrentUser -> Maybe Relationship -> Account -> Html Msg +followView currentUser relationship account = div [ class "follow-entry" ] [ accountAvatarLink account - , div [ class "username" ] + , div [ class "userinfo" ] [ strong [] - [ text <| - if account.display_name /= "" then - account.display_name - else - account.username + [ a + [ href account.url + , onClickWithPreventAndStop <| LoadAccount account.id + ] + [ text <| + if account.display_name /= "" then + account.display_name + else + account.username + ] ] , br [] [] , text <| "@" ++ account.acct ] + , followButton currentUser relationship account ] @@ -233,22 +280,23 @@ accountCounterLink label count tagger account = ] -accountView : String -> String -> Account -> Html Msg -> Html Msg -accountView label iconName account panelContent = +accountView : CurrentUser -> Account -> CurrentUserRelation -> Html Msg -> Html Msg +accountView currentUser account relationship panelContent = let { statuses_count, following_count, followers_count } = account in div [ class "col-md-3 column" ] [ div [ class "panel panel-default" ] - [ closeablePanelheading iconName label CloseAccount + [ closeablePanelheading "user" "Account" CloseAccount , div [ class "timeline" ] [ div [ class "account-detail" , style [ ( "background-image", "url('" ++ account.header ++ "')" ) ] ] [ div [ class "opacity-layer" ] - [ img [ src account.avatar ] [] + [ followButton currentUser relationship account + , img [ src account.avatar ] [] , span [ class "account-display-name" ] [ text account.display_name ] , span [ class "account-username" ] [ text ("@" ++ account.username) ] , span [ class "account-note" ] (formatContent account.note []) @@ -265,9 +313,9 @@ accountView label iconName account panelContent = ] -accountTimelineView : String -> List Status -> Account -> Html Msg -accountTimelineView label statuses account = - accountView label "user" account <| +accountTimelineView : CurrentUser -> List Status -> CurrentUserRelation -> Account -> Html Msg +accountTimelineView currentUser statuses relationship account = + accountView currentUser account relationship <| ul [ class "list-group" ] <| List.map (\s -> @@ -277,19 +325,29 @@ accountTimelineView label statuses account = statuses -accountFollowView : String -> List Account -> Account -> Html Msg -accountFollowView label accounts account = - accountView label "user" account <| +accountFollowView : + CurrentUser + -> List Account + -> List Relationship + -> CurrentUserRelation + -> Account + -> Html Msg +accountFollowView currentUser accounts relationships relationship account = + accountView currentUser account relationship <| ul [ class "list-group" ] <| List.map (\account -> li [ class "list-group-item status" ] - [ followView account ] + [ followView + currentUser + (find (\r -> r.id == account.id) relationships) + account + ] ) accounts -statusActionsView : Status -> Account -> Html Msg +statusActionsView : Status -> CurrentUser -> Html Msg statusActionsView status currentUser = let sourceStatus = @@ -353,7 +411,7 @@ statusActionsView status currentUser = ] -statusEntryView : String -> String -> Account -> Status -> Html Msg +statusEntryView : String -> String -> CurrentUser -> Status -> Html Msg statusEntryView context className currentUser status = let nsfwClass = @@ -370,7 +428,7 @@ statusEntryView context className currentUser status = ] -timelineView : String -> String -> String -> Account -> List Status -> Html Msg +timelineView : String -> String -> String -> CurrentUser -> List Status -> Html Msg timelineView label iconName context currentUser statuses = div [ class "col-md-3 column" ] [ div [ class "panel panel-default" ] @@ -396,7 +454,7 @@ notificationHeading accounts str iconType = ] -notificationStatusView : String -> Account -> Status -> NotificationAggregate -> Html Msg +notificationStatusView : String -> CurrentUser -> Status -> NotificationAggregate -> Html Msg notificationStatusView context currentUser status { type_, accounts } = div [ class <| "notification " ++ type_ ] [ case type_ of @@ -413,7 +471,7 @@ notificationStatusView context currentUser status { type_, accounts } = ] -notificationFollowView : Account -> NotificationAggregate -> Html Msg +notificationFollowView : CurrentUser -> NotificationAggregate -> Html Msg notificationFollowView currentUser { accounts } = let profileView account = @@ -434,7 +492,7 @@ notificationFollowView currentUser { accounts } = ] -notificationEntryView : Account -> NotificationAggregate -> Html Msg +notificationEntryView : CurrentUser -> NotificationAggregate -> Html Msg notificationEntryView currentUser notification = li [ class "list-group-item" ] [ case notification.status of @@ -446,7 +504,7 @@ notificationEntryView currentUser notification = ] -notificationListView : Account -> List NotificationAggregate -> Html Msg +notificationListView : CurrentUser -> List NotificationAggregate -> Html Msg notificationListView currentUser notifications = div [ class "col-md-3 column" ] [ div [ class "panel panel-default" ] @@ -482,7 +540,7 @@ draftReplyToView draft = text "" -currentUserView : Maybe Account -> Html Msg +currentUserView : Maybe CurrentUser -> Html Msg currentUserView currentUser = case currentUser of Just currentUser -> @@ -611,7 +669,7 @@ draftView { draft, currentUser } = ] -threadView : Account -> Thread -> Html Msg +threadView : CurrentUser -> Thread -> Html Msg threadView currentUser thread = let statuses = @@ -697,13 +755,27 @@ homepageView model = model.globalTimeline AccountView account -> - accountTimelineView "Account" model.accountTimeline account + accountTimelineView + currentUser + model.accountTimeline + model.accountRelationship + account AccountFollowersView account followers -> - accountFollowView "Account followers" model.accountFollowers account + accountFollowView + currentUser + model.accountFollowers + model.accountRelationships + model.accountRelationship + account AccountFollowingView account following -> - accountFollowView "Account following" model.accountFollowing account + accountFollowView + currentUser + model.accountFollowing + model.accountRelationships + model.accountRelationship + account ThreadView thread -> threadView currentUser thread