diff options
-rw-r--r-- | mastodon/accounts.go | 106 | ||||
-rw-r--r-- | mastodon/mastodon.go | 11 | ||||
-rw-r--r-- | renderer/model.go | 5 | ||||
-rw-r--r-- | renderer/renderer.go | 1 | ||||
-rw-r--r-- | service/service.go | 49 | ||||
-rw-r--r-- | service/transport.go | 58 | ||||
-rw-r--r-- | static/style.css | 46 | ||||
-rw-r--r-- | templates/nav.tmpl | 3 | ||||
-rw-r--r-- | templates/profile.tmpl | 58 |
9 files changed, 313 insertions, 24 deletions
diff --git a/mastodon/accounts.go b/mastodon/accounts.go index f4e9002..c9e0065 100644 --- a/mastodon/accounts.go +++ b/mastodon/accounts.go @@ -1,10 +1,14 @@ package mastodon import ( + "bytes" "context" "fmt" + "io" + "mime/multipart" "net/http" "net/url" + "path/filepath" "strconv" "time" ) @@ -34,6 +38,7 @@ type Account struct { Moved *Account `json:"moved"` Fields []Field `json:"fields"` Bot bool `json:"bot"` + Source *AccountSource `json:"source"` Pleroma *AccountPleroma `json:"pleroma"` } @@ -95,54 +100,115 @@ type Profile struct { Source *AccountSource // Set the base64 encoded character string of the image. - Avatar string - Header string + Avatar *multipart.FileHeader + Header *multipart.FileHeader } // AccountUpdate updates the information of the current user. func (c *Client) AccountUpdate(ctx context.Context, profile *Profile) (*Account, error) { - params := url.Values{} + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) if profile.DisplayName != nil { - params.Set("display_name", *profile.DisplayName) + err := mw.WriteField("display_name", *profile.DisplayName) + if err != nil { + return nil, err + } } if profile.Note != nil { - params.Set("note", *profile.Note) + err := mw.WriteField("note", *profile.Note) + if err != nil { + return nil, err + } } if profile.Locked != nil { - params.Set("locked", strconv.FormatBool(*profile.Locked)) + err := mw.WriteField("locked", strconv.FormatBool(*profile.Locked)) + if err != nil { + return nil, err + } } if profile.Fields != nil { for idx, field := range *profile.Fields { - params.Set(fmt.Sprintf("fields_attributes[%d][name]", idx), field.Name) - params.Set(fmt.Sprintf("fields_attributes[%d][value]", idx), field.Value) + err := mw.WriteField(fmt.Sprintf("fields_attributes[%d][name]", idx), field.Name) + if err != nil { + return nil, err + } + err = mw.WriteField(fmt.Sprintf("fields_attributes[%d][value]", idx), field.Value) + if err != nil { + return nil, err + } } } - if profile.Source != nil { - if profile.Source.Privacy != nil { - params.Set("source[privacy]", *profile.Source.Privacy) + if profile.Avatar != nil { + f, err := profile.Avatar.Open() + if err != nil { + return nil, err } - if profile.Source.Sensitive != nil { - params.Set("source[sensitive]", strconv.FormatBool(*profile.Source.Sensitive)) + fname := filepath.Base(profile.Avatar.Filename) + part, err := mw.CreateFormFile("avatar", fname) + if err != nil { + return nil, err } - if profile.Source.Language != nil { - params.Set("source[language]", *profile.Source.Language) + _, err = io.Copy(part, f) + if err != nil { + return nil, err } } - if profile.Avatar != "" { - params.Set("avatar", profile.Avatar) + if profile.Header != nil { + f, err := profile.Header.Open() + if err != nil { + return nil, err + } + fname := filepath.Base(profile.Header.Filename) + part, err := mw.CreateFormFile("header", fname) + if err != nil { + return nil, err + } + _, err = io.Copy(part, f) + if err != nil { + return nil, err + } } - if profile.Header != "" { - params.Set("header", profile.Header) + err := mw.Close() + if err != nil { + return nil, err } + params := &multipartRequest{Data: &buf, ContentType: mw.FormDataContentType()} + var account Account + err = c.doAPI(ctx, http.MethodPatch, "/api/v1/accounts/update_credentials", params, &account, nil) + if err != nil { + return nil, err + } + return &account, nil +} +func (c *Client) accountDeleteField(ctx context.Context, field string) (*Account, error) { + var buf bytes.Buffer + mw := multipart.NewWriter(&buf) + _, err := mw.CreateFormField(field) + if err != nil { + return nil, err + } + err = mw.Close() + if err != nil { + return nil, err + } + params := &multipartRequest{Data: &buf, ContentType: mw.FormDataContentType()} var account Account - err := c.doAPI(ctx, http.MethodPatch, "/api/v1/accounts/update_credentials", params, &account, nil) + err = c.doAPI(ctx, http.MethodPatch, "/api/v1/accounts/update_credentials", params, &account, nil) if err != nil { return nil, err } return &account, nil } +func (c *Client) AccountDeleteAvatar(ctx context.Context) (*Account, error) { + return c.accountDeleteField(ctx, "avatar") +} + +func (c *Client) AccountDeleteHeader(ctx context.Context) (*Account, error) { + return c.accountDeleteField(ctx, "header") +} + // GetAccountStatuses return statuses by specified accuont. func (c *Client) GetAccountStatuses(ctx context.Context, id string, onlyMedia bool, pg *Pagination) ([]*Status, error) { var statuses []*Status diff --git a/mastodon/mastodon.go b/mastodon/mastodon.go index f114169..94e2cf5 100644 --- a/mastodon/mastodon.go +++ b/mastodon/mastodon.go @@ -33,6 +33,11 @@ type Client struct { config *Config } +type multipartRequest struct { + Data io.Reader + ContentType string +} + func (c *Client) doAPI(ctx context.Context, method string, uri string, params interface{}, res interface{}, pg *Pagination) error { u, err := url.Parse(c.config.Server) if err != nil { @@ -133,6 +138,12 @@ func (c *Client) doAPI(ctx context.Context, method string, uri string, params in return err } ct = mw.FormDataContentType() + } else if mr, ok := params.(*multipartRequest); ok { + req, err = http.NewRequest(method, u.String(), mr.Data) + if err != nil { + return err + } + ct = mr.ContentType } else { if method == http.MethodGet && pg != nil { u.RawQuery = pg.toValues().Encode() diff --git a/renderer/model.go b/renderer/model.go index 8311f58..e43279d 100644 --- a/renderer/model.go +++ b/renderer/model.go @@ -156,6 +156,11 @@ type FiltersData struct { Filters []*mastodon.Filter } +type ProfileData struct { + *CommonData + User *mastodon.Account +} + type MuteData struct { *CommonData User *mastodon.Account diff --git a/renderer/renderer.go b/renderer/renderer.go index 7732554..c93a611 100644 --- a/renderer/renderer.go +++ b/renderer/renderer.go @@ -33,6 +33,7 @@ const ( SearchPage = "search.tmpl" SettingsPage = "settings.tmpl" FiltersPage = "filters.tmpl" + ProfilePage = "profile.tmpl" MutePage = "mute.tmpl" ) diff --git a/service/service.go b/service/service.go index bc9e5b8..7043310 100644 --- a/service/service.go +++ b/service/service.go @@ -774,6 +774,55 @@ func (svc *service) FiltersPage(c *client) (err error) { return svc.renderer.Render(c.rctx, c.w, renderer.FiltersPage, data) } +func (svc *service) ProfilePage(c *client) (err error) { + u, err := c.GetAccountCurrentUser(c.ctx) + if err != nil { + return + } + // Some instances allow more than 4 fields, but make sure that there are + // at least 4 fields in the slice because the template depends on it. + if u.Source.Fields == nil { + u.Source.Fields = new([]mastodon.Field) + } + for len(*u.Source.Fields) < 4 { + *u.Source.Fields = append(*u.Source.Fields, mastodon.Field{}) + } + cdata := svc.cdata(c, "edit profile", 0, 0, "") + data := &renderer.ProfileData{ + CommonData: cdata, + User: u, + } + return svc.renderer.Render(c.rctx, c.w, renderer.ProfilePage, data) +} + +func (s *service) ProfileUpdate(c *client, name, bio string, avatar, banner *multipart.FileHeader, + fields []mastodon.Field, locked bool) (err error) { + // Need to pass empty data to clear fields + if len(fields) == 0 { + fields = append(fields, mastodon.Field{}) + } + p := &mastodon.Profile{ + DisplayName: &name, + Note: &bio, + Avatar: avatar, + Header: banner, + Fields: &fields, + Locked: &locked, + } + _, err = c.AccountUpdate(c.ctx, p) + return err +} + +func (s *service) ProfileDelAvatar(c *client) (err error) { + _, err = c.AccountDeleteAvatar(c.ctx) + return +} + +func (s *service) ProfileDelBanner(c *client) (err error) { + _, err = c.AccountDeleteHeader(c.ctx) + return err +} + func (s *service) SingleInstance() (instance string, ok bool) { if len(s.instance) > 0 { instance = s.instance diff --git a/service/transport.go b/service/transport.go index 17dfca2..69d08e2 100644 --- a/service/transport.go +++ b/service/transport.go @@ -2,11 +2,14 @@ package service import ( "encoding/json" + "fmt" "log" + "mime/multipart" "net/http" "strconv" "time" + "bloat/mastodon" "bloat/model" "github.com/gorilla/mux" @@ -202,6 +205,57 @@ func NewHandler(s *service, verbose bool, staticDir string) http.Handler { return s.FiltersPage(c) }, SESSION, HTML) + profilePage := handle(func(c *client) error { + return s.ProfilePage(c) + }, SESSION, HTML) + + profileUpdate := handle(func(c *client) error { + name := c.r.FormValue("name") + bio := c.r.FormValue("bio") + var avatar, banner *multipart.FileHeader + if f := c.r.MultipartForm.File["avatar"]; len(f) > 0 { + avatar = f[0] + } + if f := c.r.MultipartForm.File["banner"]; len(f) > 0 { + banner = f[0] + } + var fields []mastodon.Field + for i := 0; i < 16; i++ { + n := c.r.FormValue(fmt.Sprintf("field-name-%d", i)) + v := c.r.FormValue(fmt.Sprintf("field-value-%d", i)) + if len(n) == 0 { + continue + } + f := mastodon.Field{Name: n, Value: v} + fields = append(fields, f) + } + locked := c.r.FormValue("locked") == "true" + err := s.ProfileUpdate(c, name, bio, avatar, banner, fields, locked) + if err != nil { + return err + } + c.redirect("/") + return nil + }, CSRF, HTML) + + profileDelAvatar := handle(func(c *client) error { + err := s.ProfileDelAvatar(c) + if err != nil { + return err + } + c.redirect(c.r.FormValue("referrer")) + return nil + }, CSRF, HTML) + + profileDelBanner := handle(func(c *client) error { + err := s.ProfileDelBanner(c) + if err != nil { + return err + } + c.redirect(c.r.FormValue("referrer")) + return nil + }, CSRF, HTML) + signin := handle(func(c *client) error { instance := c.r.FormValue("instance") url, sess, err := s.NewSession(c, instance) @@ -682,6 +736,10 @@ func NewHandler(s *service, verbose bool, staticDir string) http.Handler { r.HandleFunc("/search", searchPage).Methods(http.MethodGet) r.HandleFunc("/settings", settingsPage).Methods(http.MethodGet) r.HandleFunc("/filters", filtersPage).Methods(http.MethodGet) + r.HandleFunc("/profile", profilePage).Methods(http.MethodGet) + r.HandleFunc("/profile", profileUpdate).Methods(http.MethodPost) + r.HandleFunc("/profile/delavatar", profileDelAvatar).Methods(http.MethodPost) + r.HandleFunc("/profile/delbanner", profileDelBanner).Methods(http.MethodPost) r.HandleFunc("/signin", signin).Methods(http.MethodPost) r.HandleFunc("/oauth_callback", oauthCallback).Methods(http.MethodGet) r.HandleFunc("/post", post).Methods(http.MethodPost) diff --git a/static/style.css b/static/style.css index 21d8bc0..28683e7 100644 --- a/static/style.css +++ b/static/style.css @@ -163,15 +163,14 @@ textarea { padding: 4px; font-size: 11pt; font-family: initial; + box-sizing: border-box; } .post-content { - box-sizing: border-box; width: 100%; } -#css { - box-sizing: border-box; +#css, #bio { max-width: 100%; } @@ -434,6 +433,10 @@ img.emoji { margin-right: 2px; } +.profile-edit-link { + font-size: 8pt; +} + .user-list-item { overflow: auto; margin: 0 0 4px 0; @@ -589,6 +592,41 @@ kbd { color: #789922; } +.profile-form { + margin: 0 4px; +} + +.profile-form-field { + margin: 8px 0; +} + +.profile-avatar { + height: 96px; + width: 96px; + object-fit: contain; +} + +.profile-banner { + height: 120px; +} + +.block-label, +.profile-delete, +.profile-field, +.profile-field input { + margin: 0 0 4px 0; +} + +.profile-form input[type=text] { + width: 320px; + max-width: 100%; + box-sizing: border-box; +} + +#bio { + width: 644px; +} + .dark { background-color: #222222; background-image: none; @@ -599,7 +637,7 @@ kbd { color: #81a2be; } -.dark textarea { +.dark .post-content { background-color: #333333; border: 1px solid #444444; color: #eaeaea; diff --git a/templates/nav.tmpl b/templates/nav.tmpl index 4413823..bdb72be 100644 --- a/templates/nav.tmpl +++ b/templates/nav.tmpl @@ -12,6 +12,9 @@ <a class="nav-link" href="/user/{{.User.ID}}" accesskey="0" title="User profile (0)"> <span class="status-uname">@{{.User.Acct}}</span> </a> + <a class="profile-edit-link" href="/profile" title="edit profile" target="_top"> + edit + </a> </div> <div class="user-info-details-nav"> <a class="nav-link" href="/timeline/home" accesskey="1" title="Home timeline (1)">home</a> diff --git a/templates/profile.tmpl b/templates/profile.tmpl new file mode 100644 index 0000000..4bf1937 --- /dev/null +++ b/templates/profile.tmpl @@ -0,0 +1,58 @@ +{{with .Data}} +{{template "header.tmpl" (WithContext .CommonData $.Ctx)}} +<div class="page-title"> Edit Profile </div> + +<form class="profile-form" action="/profile" method="POST" enctype="multipart/form-data"> + <input type="hidden" name="csrf_token" value="{{$.Ctx.CSRFToken}}"> + <input type="hidden" name="referrer" value="{{$.Ctx.Referrer}}"> + <div class="profile-form-field"> + <div class="block-label"> + <label for="avatar">Avatar</label> - + <input class="btn-link" type="submit" formaction="/profile/delavatar" formmethod="POST" value="delete"> + </div> + <div> + <a href="{{.User.Avatar}}" target="_blank"> + <img class="profile-avatar" src="{{.User.Avatar}}" alt="profile-avatar" height="96"> + </a> + </div> + <div><input id="avatar" name="avatar" type="file"></div> + </div> + <div class="profile-form-field"> + <div class="block-label"> + <label for="banner">Banner</label> - + <input class="btn-link" type="submit" formaction="/profile/delbanner" formmethod="POST" value="delete"> + </div> + <div> + <a href="{{.User.Header}}" target="_blank"> + <img class="profile-banner" src="{{.User.Header}}" alt="profile-banner" height="120"> + </a> + </div> + <input id="banner" name="banner" type="file"> + </div> + <div class="profile-form-field"> + <div class="block-label"><label for="name">Name</label></div> + <div><input id="name" name="name" type="text" value="{{.User.DisplayName}}"></div> + </div> + <div class="profile-form-field"> + <div class="block-label"><label for="bio">Bio</label></div> + <textarea id="bio" name="bio" cols="80" rows="8">{{.User.Source.Note}}</textarea> + </div> + <div class="profile-form-field"> + <div class="block-label"><label>Metadata</label></div> + {{range $i, $f := .User.Source.Fields}} + <div class="profile-field"> + <input id="field-name-{{$i}}" name="field-name-{{$i}}" type="text" value="{{$f.Name}}" placeholder="name"> + <input id="field-value-{{$i}}" name="field-value-{{$i}}" type="text" value="{{$f.Value}}" placeholder="value"> + </div> + {{end}} + </div> + <div class="profile-form-field"> + <input id="locked" name="locked" type="checkbox" value="true" {{if .User.Locked}}checked{{end}}> + <label for="locked">Require manual approval of follow requests</label> + </div> + <button type="submit"> Save </button> + <button type="reset"> Reset </button> +</form> + +{{template "footer.tmpl"}} +{{end}} |