diff options
38 files changed, 632 insertions, 379 deletions
@@ -1,3 +1,3 @@ bloat database -bloat.def.conf +bloat.gen.conf @@ -15,12 +15,13 @@ This will perform a system wide installation of bloat. By default, it will install the binary in /usr/local/bin and data files in /usr/local/share/bloat. You can change these paths by editing the Makefile. -3. Edit and copy the config file -Edit the generated config file to you liking and then copy it to the default -config location. Comments in the config file describe what each config value -does. For most cases, you only need to change the value of "client_website". -$ $EDITOR bloat.def.conf -# cp bloat.def.conf /etc/bloat.conf +3. Edit the config file +bloat looks for a file named bloat.conf in the working directory and +/etc/bloat in that order. You can also specify another file by using the -f +flag. Comments in the config file describe what each config value does. For +most cases, you only need to change the value of "client_website". +# cp bloat.gen.conf /etc/bloat.conf +# $EDITOR /etc/bloat.conf 4. Create database directory Create a directory to store session information. Optionally, create a user @@ -14,16 +14,14 @@ SRC=main.go \ service/*.go \ util/*.go \ -all: bloat bloat.def.conf +all: bloat bloat: $(SRC) $(TMPL) $(GO) build $(GOFLAGS) -o bloat main.go - -bloat.def.conf: sed -e "s%=database%=/var/bloat%g" \ -e "s%=templates%=$(SHAREPATH)/templates%g" \ -e "s%=static%=$(SHAREPATH)/static%g" \ - < bloat.conf > bloat.def.conf + < bloat.conf > bloat.gen.conf install: bloat mkdir -p $(DESTDIR)$(BINPATH) \ @@ -42,4 +40,4 @@ uninstall: clean: rm -f bloat - rm -f bloat.def.conf + rm -f bloat.gen.conf @@ -15,11 +15,11 @@ Building and Installation: Typing make will build the binary $ make -Edit the provided config file. See the bloat.conf file for more details. +Edit the default config file. See the bloat.conf file for more details. $ ed bloat.conf Run the binary -$ ./bloat -f bloat.conf +$ ./bloat You can now access the frontend at http://127.0.0.1:8080, which is the default listen address. See the INSTALL file for more details. diff --git a/config/config.go b/config/config.go index 8678f52..bbb327c 100644 --- a/config/config.go +++ b/config/config.go @@ -108,21 +108,30 @@ func Parse(r io.Reader) (c *config, err error) { return } -func ParseFile(file string) (c *config, err error) { - f, err := os.Open(file) - if err != nil { - return - } - defer f.Close() - - info, err := f.Stat() - if err != nil { - return +func ParseFiles(files []string) (c *config, err error) { + var lastErr error + for _, file := range files { + f, err := os.Open(file) + if err != nil { + lastErr = err + if os.IsNotExist(err) { + continue + } + return nil, err + } + defer f.Close() + info, err := f.Stat() + if err != nil { + lastErr = err + return nil, err + } + if info.IsDir() { + continue + } + return Parse(f) } - - if info.IsDir() { - return nil, errors.New("invalid config file") + if lastErr == nil { + lastErr = errors.New("invalid config file") } - - return Parse(f) + return nil, lastErr } @@ -2,6 +2,7 @@ package main import ( "errors" + "flag" "fmt" "log" "net/http" @@ -17,7 +18,7 @@ import ( ) var ( - configFile = "/etc/bloat.conf" + configFiles = []string{"bloat.conf", "/etc/bloat.conf"} ) func errExit(err error) { @@ -26,19 +27,13 @@ func errExit(err error) { } func main() { - opts, _, err := util.Getopts(os.Args, "f:") - if err != nil { - errExit(err) - } + configFile := flag.String("f", "", "config file") + flag.Parse() - for _, opt := range opts { - switch opt.Option { - case 'f': - configFile = opt.Value - } + if len(*configFile) > 0 { + configFiles = []string{*configFile} } - - config, err := config.ParseFile(configFile) + config, err := config.ParseFiles(configFiles) if err != nil { errExit(err) } diff --git a/mastodon/accounts.go b/mastodon/accounts.go index 694e672..dbd0a48 100644 --- a/mastodon/accounts.go +++ b/mastodon/accounts.go @@ -189,6 +189,7 @@ type Relationship struct { Following bool `json:"following"` FollowedBy bool `json:"followed_by"` Blocking bool `json:"blocking"` + BlockedBy bool `json:"blocked_by"` Muting bool `json:"muting"` MutingNotifications bool `json:"muting_notifications"` Subscribing bool `json:"subscribing"` @@ -243,9 +244,13 @@ func (c *Client) AccountUnblock(ctx context.Context, id string) (*Relationship, } // AccountMute mute the account. -func (c *Client) AccountMute(ctx context.Context, id string) (*Relationship, error) { +func (c *Client) AccountMute(ctx context.Context, id string, notifications *bool) (*Relationship, error) { + params := url.Values{} + if notifications != nil { + params.Set("notifications", strconv.FormatBool(*notifications)) + } var relationship Relationship - err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%s/mute", url.PathEscape(string(id))), nil, &relationship, nil) + err := c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/accounts/%s/mute", url.PathEscape(string(id))), params, &relationship, nil) if err != nil { return nil, err } diff --git a/mastodon/helper.go b/mastodon/helper.go index 05af20f..cb0013d 100644 --- a/mastodon/helper.go +++ b/mastodon/helper.go @@ -3,12 +3,28 @@ package mastodon import ( "encoding/base64" "encoding/json" - "errors" "fmt" "net/http" "os" ) +type Error struct { + code int + err string +} + +func (e Error) Error() string { + return e.err +} + +func (e Error) IsAuthError() bool { + switch e.code { + case http.StatusForbidden, http.StatusUnauthorized: + return true + } + return false +} + // Base64EncodeFileName returns the base64 data URI format string of the file with the file name. func Base64EncodeFileName(filename string) (string, error) { file, err := os.Open(filename) @@ -51,5 +67,8 @@ func parseAPIError(prefix string, resp *http.Response) error { errMsg = fmt.Sprintf("%s: %s", errMsg, e.Error) } - return errors.New(errMsg) + return Error{ + code: resp.StatusCode, + err: errMsg, + } } diff --git a/mastodon/lists.go b/mastodon/lists.go index d323b79..1b76bdc 100644 --- a/mastodon/lists.go +++ b/mastodon/lists.go @@ -90,7 +90,7 @@ func (c *Client) DeleteList(ctx context.Context, id string) error { func (c *Client) AddToList(ctx context.Context, list string, accounts ...string) error { params := url.Values{} for _, acct := range accounts { - params.Add("account_ids", string(acct)) + params.Add("account_ids[]", string(acct)) } return c.doAPI(ctx, http.MethodPost, fmt.Sprintf("/api/v1/lists/%s/accounts", url.PathEscape(string(list))), params, nil, nil) @@ -100,7 +100,7 @@ func (c *Client) AddToList(ctx context.Context, list string, accounts ...string) func (c *Client) RemoveFromList(ctx context.Context, list string, accounts ...string) error { params := url.Values{} for _, acct := range accounts { - params.Add("account_ids", string(acct)) + params.Add("account_ids[]", string(acct)) } return c.doAPI(ctx, http.MethodDelete, fmt.Sprintf("/api/v1/lists/%s/accounts", url.PathEscape(string(list))), params, nil, nil) diff --git a/mastodon/notification.go b/mastodon/notification.go index 656e6a1..e94f901 100644 --- a/mastodon/notification.go +++ b/mastodon/notification.go @@ -23,9 +23,12 @@ type Notification struct { } // GetNotifications return notifications. -func (c *Client) GetNotifications(ctx context.Context, pg *Pagination, excludes []string) ([]*Notification, error) { +func (c *Client) GetNotifications(ctx context.Context, pg *Pagination, includes, excludes []string) ([]*Notification, error) { var notifications []*Notification params := url.Values{} + for _, include := range includes { + params.Add("include_types[]", include) + } for _, exclude := range excludes { params.Add("exclude_types[]", exclude) } diff --git a/mastodon/status.go b/mastodon/status.go index 80e7e0e..f860c31 100644 --- a/mastodon/status.go +++ b/mastodon/status.go @@ -19,6 +19,19 @@ type ReplyInfo struct { Number int `json:"number"` } +type CreatedAt struct { + time.Time +} + +func (t *CreatedAt) UnmarshalJSON(d []byte) error { + // Special case to handle retweets from GNU Social + // which returns empty string ("") in created_at + if len(d) == 2 && string(d) == `""` { + return nil + } + return t.Time.UnmarshalJSON(d) +} + // Status is struct to hold status. type Status struct { ID string `json:"id"` @@ -29,7 +42,7 @@ type Status struct { InReplyToAccountID interface{} `json:"in_reply_to_account_id"` Reblog *Status `json:"reblog"` Content string `json:"content"` - CreatedAt time.Time `json:"created_at"` + CreatedAt CreatedAt `json:"created_at"` Emojis []Emoji `json:"emojis"` RepliesCount int64 `json:"replies_count"` ReblogsCount int64 `json:"reblogs_count"` @@ -43,7 +56,6 @@ type Status struct { MediaAttachments []Attachment `json:"media_attachments"` Mentions []Mention `json:"mentions"` Tags []Tag `json:"tags"` - Card *Card `json:"card"` Application Application `json:"application"` Language string `json:"language"` Pinned interface{} `json:"pinned"` @@ -64,22 +76,6 @@ type Context struct { Descendants []*Status `json:"descendants"` } -// Card hold information for mastodon card. -type Card struct { - URL string `json:"url"` - Title string `json:"title"` - Description string `json:"description"` - Image string `json:"image"` - Type string `json:"type"` - AuthorName string `json:"author_name"` - AuthorURL string `json:"author_url"` - ProviderName string `json:"provider_name"` - ProviderURL string `json:"provider_url"` - HTML string `json:"html"` - Width int64 `json:"width"` - Height int64 `json:"height"` -} - // GetFavourites return the favorite list of the current user. func (c *Client) GetFavourites(ctx context.Context, pg *Pagination) ([]*Status, error) { var statuses []*Status @@ -110,16 +106,6 @@ func (c *Client) GetStatusContext(ctx context.Context, id string) (*Context, err return &context, nil } -// GetStatusCard return status specified by id. -func (c *Client) GetStatusCard(ctx context.Context, id string) (*Card, error) { - var card Card - err := c.doAPI(ctx, http.MethodGet, fmt.Sprintf("/api/v1/statuses/%s/card", id), nil, &card, nil) - if err != nil { - return nil, err - } - return &card, nil -} - // GetRebloggedBy returns the account list of the user who reblogged the toot of id. func (c *Client) GetRebloggedBy(ctx context.Context, id string, pg *Pagination) ([]*Account, error) { var accounts []*Account @@ -288,7 +274,7 @@ func (c *Client) DeleteStatus(ctx context.Context, id string) error { } // Search search content with query. -func (c *Client) Search(ctx context.Context, q string, qType string, limit int, resolve bool, offset int, accountID string) (*Results, error) { +func (c *Client) Search(ctx context.Context, q string, qType string, limit int, resolve bool, offset int, accountID string, following bool) (*Results, error) { var results Results params := url.Values{} params.Set("q", q) @@ -296,6 +282,7 @@ func (c *Client) Search(ctx context.Context, q string, qType string, limit int, params.Set("limit", fmt.Sprint(limit)) params.Set("resolve", fmt.Sprint(resolve)) params.Set("offset", fmt.Sprint(offset)) + params.Set("following", fmt.Sprint(following)) if len(accountID) > 0 { params.Set("account_id", accountID) } diff --git a/model/settings.go b/model/settings.go index c4e8aec..1f83c75 100644 --- a/model/settings.go +++ b/model/settings.go @@ -1,31 +1,33 @@ package model type Settings struct { - DefaultVisibility string `json:"default_visibility"` - DefaultFormat string `json:"default_format"` - CopyScope bool `json:"copy_scope"` - ThreadInNewTab bool `json:"thread_in_new_tab"` - HideAttachments bool `json:"hide_attachments"` - MaskNSFW bool `json:"mask_nfsw"` - NotificationInterval int `json:"notifications_interval"` - FluorideMode bool `json:"fluoride_mode"` - DarkMode bool `json:"dark_mode"` - AntiDopamineMode bool `json:"anti_dopamine_mode"` - CSS string `json:"css"` + DefaultVisibility string `json:"default_visibility"` + DefaultFormat string `json:"default_format"` + CopyScope bool `json:"copy_scope"` + ThreadInNewTab bool `json:"thread_in_new_tab"` + HideAttachments bool `json:"hide_attachments"` + MaskNSFW bool `json:"mask_nfsw"` + NotificationInterval int `json:"notifications_interval"` + FluorideMode bool `json:"fluoride_mode"` + DarkMode bool `json:"dark_mode"` + AntiDopamineMode bool `json:"anti_dopamine_mode"` + HideUnsupportedNotifs bool `json:"hide_unsupported_notifs"` + CSS string `json:"css"` } func NewSettings() *Settings { return &Settings{ - DefaultVisibility: "public", - DefaultFormat: "", - CopyScope: true, - ThreadInNewTab: false, - HideAttachments: false, - MaskNSFW: true, - NotificationInterval: 0, - FluorideMode: false, - DarkMode: false, - AntiDopamineMode: false, - CSS: "", + DefaultVisibility: "public", + DefaultFormat: "", + CopyScope: true, + ThreadInNewTab: false, + HideAttachments: false, + MaskNSFW: true, + NotificationInterval: 0, + FluorideMode: false, + DarkMode: false, + AntiDopamineMode: false, + HideUnsupportedNotifs: false, + CSS: "", } } diff --git a/renderer/model.go b/renderer/model.go index 385ac7c..726cee9 100644 --- a/renderer/model.go +++ b/renderer/model.go @@ -62,6 +62,19 @@ type TimelineData struct { PrevLink string } +type ListsData struct { + *CommonData + Lists []*mastodon.List +} + +type ListData struct { + *CommonData + List *mastodon.List + Accounts []*mastodon.Account + Q string + SearchAccounts []*mastodon.Account +} + type ThreadData struct { *CommonData Statuses []*mastodon.Status diff --git a/renderer/renderer.go b/renderer/renderer.go index 6c9877a..50fced7 100644 --- a/renderer/renderer.go +++ b/renderer/renderer.go @@ -1,11 +1,11 @@ package renderer import ( - "fmt" + "html/template" "io" + "regexp" "strconv" "strings" - "text/template" "time" "bloat/mastodon" @@ -19,6 +19,8 @@ const ( NavPage = "nav.tmpl" RootPage = "root.tmpl" TimelinePage = "timeline.tmpl" + ListsPage = "lists.tmpl" + ListPage = "list.tmpl" ThreadPage = "thread.tmpl" QuickReplyPage = "quickreply.tmpl" StatusPopup = "status.tmpl" @@ -39,29 +41,25 @@ type TemplateData struct { Ctx *Context } +func emojiHTML(e mastodon.Emoji, height string) string { + return `<img class="emoji" src="` + e.URL + `" alt=":` + e.ShortCode + `:" title=":` + e.ShortCode + `:" height="` + height + `"/>` +} + func emojiFilter(content string, emojis []mastodon.Emoji) string { var replacements []string - var r string for _, e := range emojis { - r = fmt.Sprintf("<img class=\"emoji\" src=\"%s\" alt=\":%s:\" title=\":%s:\" height=\"24\" />", - e.URL, e.ShortCode, e.ShortCode) - replacements = append(replacements, ":"+e.ShortCode+":", r) + replacements = append(replacements, ":"+e.ShortCode+":", emojiHTML(e, "24")) } return strings.NewReplacer(replacements...).Replace(content) } -func statusContentFilter(spoiler string, content string, - emojis []mastodon.Emoji, mentions []mastodon.Mention) string { +var quoteRE = regexp.MustCompile("(?mU)(^|> *|\n)(>.*)(<br|$)") +func statusContentFilter(content string, emojis []mastodon.Emoji, mentions []mastodon.Mention) string { + content = quoteRE.ReplaceAllString(content, `$1<span class="quote">$2</span>$3`) var replacements []string - var r string - if len(spoiler) > 0 { - content = spoiler + "<br />" + content - } for _, e := range emojis { - r = fmt.Sprintf("<img class=\"emoji\" src=\"%s\" alt=\":%s:\" title=\":%s:\" height=\"32\" />", - e.URL, e.ShortCode, e.ShortCode) - replacements = append(replacements, ":"+e.ShortCode+":", r) + replacements = append(replacements, ":"+e.ShortCode+":", emojiHTML(e, "32")) } for _, m := range mentions { replacements = append(replacements, `"`+m.URL+`"`, `"/user/`+m.ID+`" title="@`+m.Acct+`"`) @@ -76,45 +74,41 @@ func displayInteractionCount(c int64) string { return "" } -func DurToStr(dur time.Duration) string { - s := dur.Seconds() +func durUnit(s int64) (dur int64, unit string) { if s < 60 { - return strconv.Itoa(int(s)) + "s" + if s < 0 { + s = 0 + } + return s, "s" } - m := dur.Minutes() - if m < 60*2 { - return strconv.Itoa(int(m)) + "m" - } - h := dur.Hours() - if h < 24*2 { - return strconv.Itoa(int(h)) + "h" + m := s / 60 + h := m / 60 + if h < 2 { + return m, "m" } d := h / 24 - if d < 30*2 { - return strconv.Itoa(int(d)) + "d" + if d < 2 { + return h, "h" } mo := d / 30 - if mo < 12*2 { - return strconv.Itoa(int(mo)) + "mo" + if mo < 2 { + return d, "d" + } + y := d / 365 + if y < 2 { + return mo, "mo" } - y := mo / 12 - return strconv.Itoa(int(y)) + "y" + return y, "y" } func timeSince(t time.Time) string { - d := time.Since(t) - if d < 0 { - d = 0 - } - return DurToStr(d) + d, u := durUnit(time.Now().Unix() - t.Unix()) + return strconv.FormatInt(d, 10) + u } func timeUntil(t time.Time) string { - d := time.Until(t) - if d < 0 { - d = 0 - } - return DurToStr(d) + d, u := durUnit(t.Unix() - time.Now().Unix()) + return strconv.FormatInt(d, 10) + u } func formatTimeRFC3339(t time.Time) string { @@ -129,6 +123,14 @@ func withContext(data interface{}, ctx *Context) TemplateData { return TemplateData{data, ctx} } +func raw(s string) template.HTML { + return template.HTML(s) +} + +func rawCSS(s string) template.CSS { + return template.CSS(s) +} + type Renderer interface { Render(ctx *Context, writer io.Writer, page string, data interface{}) (err error) } @@ -148,6 +150,9 @@ func NewRenderer(templateGlobPattern string) (r *renderer, err error) { "FormatTimeRFC3339": formatTimeRFC3339, "FormatTimeRFC822": formatTimeRFC822, "WithContext": withContext, + "HTML": template.HTMLEscapeString, + "Raw": raw, + "RawCSS": rawCSS, }).ParseGlob(templateGlobPattern) if err != nil { return diff --git a/service/service.go b/service/service.go index a846322..8bccd45 100644 --- a/service/service.go +++ b/service/service.go @@ -114,7 +114,8 @@ func (s *service) ErrorPage(c *client, err error, retry bool) error { var sessionErr bool if err != nil { errStr = err.Error() - if err == errInvalidSession || err == errInvalidCSRFToken { + if me, ok := err.(mastodon.Error); ok && me.IsAuthError() || + err == errInvalidSession || err == errInvalidCSRFToken { sessionErr = true } } @@ -162,8 +163,8 @@ func (s *service) NavPage(c *client) (err error) { return s.renderer.Render(c.rctx, c.w, renderer.NavPage, data) } -func (s *service) TimelinePage(c *client, tType string, instance string, - maxID string, minID string) (err error) { +func (s *service) TimelinePage(c *client, tType, instance, listId, maxID, + minID string) (err error) { var nextLink, prevLink, title string var statuses []*mastodon.Status @@ -178,24 +179,46 @@ func (s *service) TimelinePage(c *client, tType string, instance string, return errInvalidArgument case "home": statuses, err = c.GetTimelineHome(c.ctx, &pg) + if err != nil { + return err + } title = "Timeline" case "direct": statuses, err = c.GetTimelineDirect(c.ctx, &pg) + if err != nil { + return err + } title = "Direct Timeline" case "local": statuses, err = c.GetTimelinePublic(c.ctx, true, "", &pg) + if err != nil { + return err + } title = "Local Timeline" case "remote": if len(instance) > 0 { statuses, err = c.GetTimelinePublic(c.ctx, false, instance, &pg) + if err != nil { + return err + } } title = "Remote Timeline" case "twkn": statuses, err = c.GetTimelinePublic(c.ctx, false, "", &pg) + if err != nil { + return err + } title = "The Whole Known Network" - } - if err != nil { - return err + case "list": + statuses, err = c.GetTimelineList(c.ctx, listId, &pg) + if err != nil { + return err + } + list, err := c.GetList(c.ctx, listId) + if err != nil { + return err + } + title = "List Timeline - " + list.Title } for i := range statuses { @@ -210,6 +233,9 @@ func (s *service) TimelinePage(c *client, tType string, instance string, if len(instance) > 0 { v.Set("instance", instance) } + if len(listId) > 0 { + v.Set("list", listId) + } prevLink = "/timeline/" + tType + "?" + v.Encode() } @@ -219,6 +245,9 @@ func (s *service) TimelinePage(c *client, tType string, instance string, if len(instance) > 0 { v.Set("instance", instance) } + if len(listId) > 0 { + v.Set("list", listId) + } nextLink = "/timeline/" + tType + "?" + v.Encode() } @@ -251,6 +280,70 @@ func addToReplyMap(m map[string][]mastodon.ReplyInfo, key interface{}, m[keyStr] = append(m[keyStr], mastodon.ReplyInfo{val, number}) } +func (s *service) ListsPage(c *client) (err error) { + lists, err := c.GetLists(c.ctx) + if err != nil { + return + } + + cdata := s.cdata(c, "Lists", 0, 0, "") + data := renderer.ListsData{ + Lists: lists, + CommonData: cdata, + } + return s.renderer.Render(c.rctx, c.w, renderer.ListsPage, data) +} + +func (s *service) AddList(c *client, title string) (err error) { + _, err = c.CreateList(c.ctx, title) + return err +} + +func (s *service) RemoveList(c *client, id string) (err error) { + return c.DeleteList(c.ctx, id) +} + +func (s *service) RenameList(c *client, id, title string) (err error) { + _, err = c.RenameList(c.ctx, id, title) + return err +} + +func (s *service) ListPage(c *client, id string, q string) (err error) { + list, err := c.GetList(c.ctx, id) + if err != nil { + return + } + accounts, err := c.GetListAccounts(c.ctx, id) + if err != nil { + return + } + var searchAccounts []*mastodon.Account + if len(q) > 0 { + result, err := c.Search(c.ctx, q, "accounts", 20, true, 0, id, true) + if err != nil { + return err + } + searchAccounts = result.Accounts + } + cdata := s.cdata(c, "List "+list.Title, 0, 0, "") + data := renderer.ListData{ + List: list, + Accounts: accounts, + Q: q, + SearchAccounts: searchAccounts, + CommonData: cdata, + } + return s.renderer.Render(c.rctx, c.w, renderer.ListPage, data) +} + +func (s *service) ListAddUser(c *client, id string, uid string) (err error) { + return c.AddToList(c.ctx, id, uid) +} + +func (s *service) ListRemoveUser(c *client, id string, uid string) (err error) { + return c.RemoveFromList(c.ctx, id, uid) +} + func (s *service) ThreadPage(c *client, id string, reply bool) (err error) { var pctx model.PostContext @@ -417,18 +510,24 @@ func (s *service) NotificationPage(c *client, maxID string, var nextLink string var unreadCount int var readID string - var excludes []string + var includes, excludes []string var pg = mastodon.Pagination{ MaxID: maxID, MinID: minID, Limit: 20, } + if c.s.Settings.HideUnsupportedNotifs { + // Explicitly include the supported types. + // For now, only Pleroma supports this option, Mastadon + // will simply ignore the unknown params. + includes = []string{"follow", "follow_request", "mention", "reblog", "favourite"} + } if c.s.Settings.AntiDopamineMode { - excludes = []string{"follow", "favourite", "reblog"} + excludes = append(excludes, "follow", "favourite", "reblog") } - notifications, err := c.GetNotifications(c.ctx, &pg, excludes) + notifications, err := c.GetNotifications(c.ctx, &pg, includes, excludes) if err != nil { return } @@ -609,7 +708,7 @@ func (s *service) UserSearchPage(c *client, var results *mastodon.Results if len(q) > 0 { - results, err = c.Search(c.ctx, q, "statuses", 20, true, offset, id) + results, err = c.Search(c.ctx, q, "statuses", 20, true, offset, id, false) if err != nil { return err } @@ -620,7 +719,7 @@ func (s *service) UserSearchPage(c *client, if len(results.Statuses) == 20 { offset += 20 nextLink = fmt.Sprintf("/usersearch/%s?q=%s&offset=%d", id, - url.QueryEscape(q), offset) + q, offset) } if len(q) > 0 { @@ -667,7 +766,7 @@ func (s *service) SearchPage(c *client, var results *mastodon.Results if len(q) > 0 { - results, err = c.Search(c.ctx, q, qType, 20, true, offset, "") + results, err = c.Search(c.ctx, q, qType, 20, true, offset, "", false) if err != nil { return err } @@ -679,7 +778,7 @@ func (s *service) SearchPage(c *client, (qType == "statuses" && len(results.Statuses) == 20) { offset += 20 nextLink = fmt.Sprintf("/search?q=%s&type=%s&offset=%d", - url.QueryEscape(q), qType, offset) + q, qType, offset) } if len(q) > 0 { @@ -914,8 +1013,8 @@ func (s *service) Reject(c *client, id string) (err error) { return c.FollowRequestReject(c.ctx, id) } -func (s *service) Mute(c *client, id string) (err error) { - _, err = c.AccountMute(c.ctx, id) +func (s *service) Mute(c *client, id string, notifications *bool) (err error) { + _, err = c.AccountMute(c.ctx, id, notifications) return } diff --git a/service/transport.go b/service/transport.go index a022b02..1fbce99 100644 --- a/service/transport.go +++ b/service/transport.go @@ -169,9 +169,10 @@ func NewHandler(s *service, logger *log.Logger, staticDir string) http.Handler { tType, _ := mux.Vars(c.r)["type"] q := c.r.URL.Query() instance := q.Get("instance") + list := q.Get("list") maxID := q.Get("max_id") minID := q.Get("min_id") - return s.TimelinePage(c, tType, instance, maxID, minID) + return s.TimelinePage(c, tType, instance, list, maxID, minID) }, SESSION, HTML) defaultTimelinePage := handle(func(c *client) error { @@ -415,7 +416,13 @@ func NewHandler(s *service, logger *log.Logger, staticDir string) http.Handler { mute := handle(func(c *client) error { id, _ := mux.Vars(c.r)["id"] - err := s.Mute(c, id) + q := c.r.URL.Query() + var notifications *bool + if r, ok := q["notifications"]; ok && len(r) > 0 { + notifications = new(bool) + *notifications = r[0] == "true" + } + err := s.Mute(c, id, notifications) if err != nil { return err } @@ -484,20 +491,22 @@ func NewHandler(s *service, logger *log.Logger, staticDir string) http.Handler { fluorideMode := c.r.FormValue("fluoride_mode") == "true" darkMode := c.r.FormValue("dark_mode") == "true" antiDopamineMode := c.r.FormValue("anti_dopamine_mode") == "true" + hideUnsupportedNotifs := c.r.FormValue("hide_unsupported_notifs") == "true" css := c.r.FormValue("css") settings := &model.Settings{ - DefaultVisibility: visibility, - DefaultFormat: format, - CopyScope: copyScope, - ThreadInNewTab: threadInNewTab, - HideAttachments: hideAttachments, - MaskNSFW: maskNSFW, - NotificationInterval: ni, - FluorideMode: fluorideMode, - DarkMode: darkMode, - AntiDopamineMode: antiDopamineMode, - CSS: css, + DefaultVisibility: visibility, + DefaultFormat: format, + CopyScope: copyScope, + ThreadInNewTab: threadInNewTab, + HideAttachments: hideAttachments, + MaskNSFW: maskNSFW, + NotificationInterval: ni, + FluorideMode: fluorideMode, + DarkMode: darkMode, + AntiDopamineMode: antiDopamineMode, + HideUnsupportedNotifs: hideUnsupportedNotifs, + CSS: css, } err := s.SaveSettings(c, settings) @@ -598,6 +607,72 @@ func NewHandler(s *service, logger *log.Logger, staticDir string) http.Handler { return nil }, CSRF, HTML) + listsPage := handle(func(c *client) error { + return s.ListsPage(c) + }, SESSION, HTML) + + addList := handle(func(c *client) error { + title := c.r.FormValue("title") + err := s.AddList(c, title) + if err != nil { + return err + } + redirect(c, c.r.FormValue("referrer")) + return nil + }, CSRF, HTML) + + removeList := handle(func(c *client) error { + id, _ := mux.Vars(c.r)["id"] + err := s.RemoveList(c, id) + if err != nil { + return err + } + redirect(c, c.r.FormValue("referrer")) + return nil + }, CSRF, HTML) + + renameList := handle(func(c *client) error { + id, _ := mux.Vars(c.r)["id"] + title := c.r.FormValue("title") + err := s.RenameList(c, id, title) + if err != nil { + return err + } + redirect(c, c.r.FormValue("referrer")) + return nil + }, CSRF, HTML) + + listPage := handle(func(c *client) error { + id, _ := mux.Vars(c.r)["id"] + q := c.r.URL.Query() + sq := q.Get("q") + return s.ListPage(c, id, sq) + }, SESSION, HTML) + + listAddUser := handle(func(c *client) error { + id, _ := mux.Vars(c.r)["id"] + q := c.r.URL.Query() + uid := q.Get("uid") + err := s.ListAddUser(c, id, uid) + if err != nil { + return err + } + redirect(c, c.r.FormValue("referrer")) + return nil + }, CSRF, HTML) + + listRemoveUser := handle(func(c *client) error { + id, _ := mux.Vars(c.r)["id"] + q := c.r.URL.Query() + uid := q.Get("uid") + err := s.ListRemoveUser(c, id, uid) + if err != nil { + return err + } + redirect(c, c.r.FormValue("referrer")) + return nil + }, CSRF, HTML) + signout := handle(func(c *client) error { s.Signout(c) setSessionCookie(c.w, "", 0) @@ -691,6 +766,13 @@ func NewHandler(s *service, logger *log.Logger, staticDir string) http.Handler { r.HandleFunc("/unbookmark/{id}", unBookmark).Methods(http.MethodPost) r.HandleFunc("/filter", filter).Methods(http.MethodPost) r.HandleFunc("/unfilter/{id}", unFilter).Methods(http.MethodPost) + r.HandleFunc("/lists", listsPage).Methods(http.MethodGet) + r.HandleFunc("/list", addList).Methods(http.MethodPost) + r.HandleFunc("/list/{id}", listPage).Methods(http.MethodGet) + r.HandleFunc("/list/{id}/remove", removeList).Methods(http.MethodPost) + r.HandleFunc("/list/{id}/rename", renameList).Methods(http.MethodPost) + r.HandleFunc("/list/{id}/adduser", listAddUser).Methods(http.MethodPost) + r.HandleFunc("/list/{id}/removeuser", listRemoveUser).Methods(http.MethodPost) r.HandleFunc("/signout", signout).Methods(http.MethodPost) r.HandleFunc("/fluoride/like/{id}", fLike).Methods(http.MethodPost) r.HandleFunc("/fluoride/unlike/{id}", fUnlike).Methods(http.MethodPost) diff --git a/static/fluoride.js b/static/fluoride.js index c7f3109..e6624d1 100644 --- a/static/fluoride.js +++ b/static/fluoride.js @@ -152,6 +152,7 @@ function replyToLinkLocal(a) { var copy = status.cloneNode(true); copy.id = "reply-to-popup"; var ract = event.target.getBoundingClientRect(); + copy.style["max-width"] = (window.innerWidth - ract.left - 32) + "px"; if (ract.top > window.innerHeight / 2) { copy.style.bottom = (window.innerHeight - window.scrollY - ract.top) + "px"; @@ -245,6 +246,7 @@ function handleReplyLink(a) { var copy = status.cloneNode(true); copy.id = "reply-popup"; var ract = event.target.getBoundingClientRect(); + copy.style["max-width"] = (window.innerWidth - 98) + "px"; if (ract.left > window.innerWidth / 2) { copy.style.right = (window.innerWidth - ract.right - 12) + "px"; @@ -298,20 +300,24 @@ function setPos(el, cx, cy, mw, mh) { } var imgPrev = null; +var imgX = 0; +var imgY = 0; function handleImgPreview(a) { a.onmouseenter = function(e) { var mw = document.documentElement.clientWidth; var mh = document.documentElement.clientHeight - 24; + imgX = e.clientX; + imgY = e.clientY; var img = document.createElement("img"); img.id = "img-preview"; img.src = e.target.getAttribute("href"); img.style["max-width"] = mw + "px"; img.style["max-height"] = mh + "px"; + imgPrev = img; img.onload = function(e2) { - setPos(e2.target, e.clientX, e.clientY, mw, mh); + setPos(imgPrev, imgX, imgY, mw, mh); } document.body.appendChild(img); - imgPrev = img; } a.onmouseleave = function(e) { var img = document.getElementById("img-preview"); @@ -324,8 +330,24 @@ function handleImgPreview(a) { return; var mw = document.documentElement.clientWidth; var mh = document.documentElement.clientHeight - 24; - setPos(imgPrev, e.clientX, e.clientY, mw, mh); + imgX = e.clientX; + imgY = e.clientY; + setPos(imgPrev, imgX, imgY, mw, mh); + } +} + +function onPaste(e) { + if (!e.clipboardData.files) + return; + var fp = document.querySelector("#post-file-picker") + var dt = new DataTransfer(); + for (var i = 0; i < fp.files.length; i++) { + dt.items.add(fp.files[i]); } + for (var i = 0; i < e.clipboardData.files.length; i++) { + dt.items.add(e.clipboardData.files[i]); + } + fp.files = dt.files; } document.addEventListener("DOMContentLoaded", function() { @@ -357,7 +379,7 @@ document.addEventListener("DOMContentLoaded", function() { } } - var links = document.querySelectorAll(".user-profile-decription a"); + var links = document.querySelectorAll(".user-profile-decription a, .user-fields a"); for (var j = 0; j < links.length; j++) { links[j].target = "_blank"; } @@ -366,6 +388,10 @@ document.addEventListener("DOMContentLoaded", function() { for (var j = 0; j < links.length; j++) { handleImgPreview(links[j]); } + + var pf = document.querySelector(".post-form") + if (pf) + pf.addEventListener("paste", onPaste); }); // @license-end diff --git a/static/style.css b/static/style.css index cd7e98c..19cceab 100644 --- a/static/style.css +++ b/static/style.css @@ -194,11 +194,14 @@ textarea { border-color: #777777; } -.notification-container.favourite .status-container, -.notification-container.reblog .status-container { +.notification-container .status-container { opacity: 0.6; } +.notification-container.mention .status-container { + opacity: unset; +} + .notification-info-text span { vertical-align: middle; } @@ -277,7 +280,8 @@ textarea { margin-top: 2px; } -.user-profile-decription { +.user-profile-decription, +.user-fields { overflow-wrap: break-word; margin: 8px 0; } @@ -286,10 +290,22 @@ textarea { margin: 0; } +.user-profile-decription img { + height: auto; + width: auto; + max-height: 240px; + max-width: 280px; + object-fit: contain; +} + .d-inline { display: inline; } +.p-0 { + padding: 0; +} + .btn-link { border: none; outline: none; @@ -354,10 +370,6 @@ a:hover, display: none; } -.post-form-field>* { - vertical-align: middle; -} - .emoji-item-container { width: 220px; display: inline-block; @@ -422,9 +434,6 @@ img.emoji { margin-right: 2px; } -.user-list-container { -} - .user-list-item { overflow: auto; margin: 0 0 12px 0; @@ -441,6 +450,10 @@ img.emoji { overflow: auto; } +.user-list-action { + margin: 0 12px; +} + #settings-form { margin: 8px 0; } @@ -449,10 +462,6 @@ img.emoji { margin: 4px 0; } -.settings-form-field>* { - vertical-align: middle; -} - #settings-form button[type=submit] { margin-top: 8px; } @@ -517,16 +526,16 @@ img.emoji { margin-top: 6px; } -.notification-title-container { +.page-title-container { margin: 8px 0; } -.notification-text { - vertical-align: middle; +.page-refresh { + margin-right: 8px; } -.notification-refresh { - margin-right: 8px; +.notification-text { + vertical-align: middle; } .notification-read { @@ -575,6 +584,10 @@ kbd { position: fixed; } +.quote { + color: #789922; +} + .dark { background-color: #222222; background-image: none; diff --git a/templates/about.tmpl b/templates/about.tmpl index c0b8418..0e4d001 100644 --- a/templates/about.tmpl +++ b/templates/about.tmpl @@ -46,11 +46,11 @@ <td> <kbd>6</kbd> </td> </tr> <tr> - <td> Settings </td> + <td> Lists </td> <td> <kbd>7</kbd> </td> </tr> <tr> - <td> Signout </td> + <td> Settings </td> <td> <kbd>8</kbd> </td> </tr> <tr> @@ -94,7 +94,7 @@ <td> <kbd>C</kbd> </td> </tr> <tr> - <td> Refresh thread page </td> + <td> Refresh timeline/thread page </td> <td> <kbd>T</kbd> </td> </tr> </table> diff --git a/templates/emoji.tmpl b/templates/emoji.tmpl index ee84522..4b07e81 100644 --- a/templates/emoji.tmpl +++ b/templates/emoji.tmpl @@ -6,7 +6,7 @@ {{range .Emojis}} <div class="emoji-item-container"> <div class="emoji-item"> - <img class="emoji" src="{{.URL}}" alt="{{.ShortCode}}" height="32" /> + <img class="emoji" src="{{.URL}}" alt="{{.ShortCode}}" height="32" loading="lazy" /> <span title=":{{.ShortCode}}:" class="emoji-shortcode">:{{.ShortCode}}:</span> </div> </div> diff --git a/templates/header.tmpl b/templates/header.tmpl index 8eb53f6..8a1b0ca 100644 --- a/templates/header.tmpl +++ b/templates/header.tmpl @@ -17,7 +17,7 @@ {{if .RefreshInterval}} <meta http-equiv="refresh" content="{{.RefreshInterval}}"> {{end}} - <title> {{if gt .Count 0}}({{.Count}}){{end}} {{.Title | html}} </title> + <title> {{if gt .Count 0}}({{.Count}}){{end}} {{.Title}} </title> <link rel="stylesheet" href="/static/style.css"> {{if .CustomCSS}} <link rel="stylesheet" href="{{.CustomCSS}}"> @@ -26,7 +26,7 @@ <script src="/static/fluoride.js"></script> {{end}} {{if $.Ctx.UserCSS}} - <style>{{$.Ctx.UserCSS}}</style> + <style>{{RawCSS $.Ctx.UserCSS}}</style> {{end}} </head> <body {{if $.Ctx.DarkMode}}class="dark"{{end}}> diff --git a/templates/list.tmpl b/templates/list.tmpl new file mode 100644 index 0000000..dcc6ee8 --- /dev/null +++ b/templates/list.tmpl @@ -0,0 +1,63 @@ +{{with .Data}} +{{template "header.tmpl" (WithContext .CommonData $.Ctx)}} +<div class="page-title"> List {{.List.Title}} </div> + +<form action="/list/{{.List.ID}}/rename" method="POST"> + <input type="hidden" name="csrf_token" value="{{$.Ctx.CSRFToken}}"> + <input type="hidden" name="referrer" value="{{$.Ctx.Referrer}}"> + <input id="title" name="title" value="{{.List.Title}}"> + <button type="submit"> Rename </button> +</form> + +<div class="page-title"> Users </div> +{{if .Accounts}} +<table> +{{range .Accounts}} + <tr> + <td class="p-0"> {{template "userlistitem.tmpl" (WithContext . $.Ctx)}} </td> + <td class="p-0"> + <form class="user-list-action" action="/list/{{$.Data.List.ID}}/removeuser?uid={{.ID}}" method="POST"> + <input type="hidden" name="csrf_token" value="{{$.Ctx.CSRFToken}}"> + <input type="hidden" name="referrer" value="{{$.Ctx.Referrer}}"> + <button type="submit"> Remove </button> + </form> + </td> + </tr> +{{end}} +</table> +{{else}} +<div class="no-data-found">No data found</div> +{{end}} + +<div class="page-title"> Add user </div> +<form class="search-form" action="/list/{{.List.ID}}" method="GET"> + <span class="post-form-field"> + <label for="query"> Query </label> + <input id="query" name="q" value="{{.Q}}"> + </span> + <button type="submit"> Search </button> +</form> + +{{if .Q}} +{{if .SearchAccounts}} +<table> +{{range .SearchAccounts}} + <tr> + <td> {{template "userlistitem.tmpl" (WithContext . $.Ctx)}} </td> + <td> + <form class="user-list-action" action="/list/{{$.Data.List.ID}}/adduser?uid={{.ID}}" method="POST"> + <input type="hidden" name="csrf_token" value="{{$.Ctx.CSRFToken}}"> + <input type="hidden" name="referrer" value="{{$.Ctx.Referrer}}"> + <button type="submit"> Add </button> + </form> + </td> + </tr> +{{end}} +</table> +{{else}} +<div class="no-data-found">No data found</div> +{{end}} +{{end}} + +{{template "footer.tmpl"}} +{{end}} diff --git a/templates/lists.tmpl b/templates/lists.tmpl new file mode 100644 index 0000000..27979cb --- /dev/null +++ b/templates/lists.tmpl @@ -0,0 +1,35 @@ +{{with .Data}} +{{template "header.tmpl" (WithContext .CommonData $.Ctx)}} +<div class="page-title"> Lists </div> + +{{range .Lists}} +<div> + <a href="/timeline/list?list={{.ID}}"> {{.Title}} timeline </a> + - + <form class="d-inline" action="/list/{{.ID}}" method="GET"> + <button type="submit" class="btn-link"> edit </button> + </form> + - + <form class="d-inline" action="/list/{{.ID}}/remove" method="POST"> + <input type="hidden" name="csrf_token" value="{{$.Ctx.CSRFToken}}"> + <input type="hidden" name="referrer" value="{{$.Ctx.Referrer}}"> + <button type="submit" class="btn-link"> delete </button> + </form> +</div> +{{else}} +<div class="no-data-found">No data found</div> +{{end}} + +<div class="page-title"> Add list </div> +<form action="/list" method="POST"> + <input type="hidden" name="csrf_token" value="{{$.Ctx.CSRFToken}}"> + <input type="hidden" name="referrer" value="{{$.Ctx.Referrer}}"> + <span class="settings-form-field"> + <label for="title"> Title </label> + <input id="title" name="title" required> + </span> + <button type="submit"> Add </button> +</form> + +{{template "footer.tmpl"}} +{{end}} diff --git a/templates/nav.tmpl b/templates/nav.tmpl index cbf65c9..db88aa0 100644 --- a/templates/nav.tmpl +++ b/templates/nav.tmpl @@ -8,7 +8,7 @@ </div> <div class="user-info-details-container"> <div class="user-info-details-name"> - <bdi class="status-dname"> {{EmojiFilter .User.DisplayName .User.Emojis}} </bdi> + <bdi class="status-dname"> {{EmojiFilter (HTML .User.DisplayName) .User.Emojis | Raw}} </bdi> <a class="nav-link" href="/user/{{.User.ID}}" accesskey="0" title="User profile (0)"> <span class="status-uname"> @{{.User.Acct}} </span> </a> @@ -17,16 +17,17 @@ <a class="nav-link" href="/timeline/home" accesskey="1" title="Home timeline (1)">home</a> <a class="nav-link" href="/timeline/direct" accesskey="2" title="Direct timeline (2)">direct</a> <a class="nav-link" href="/timeline/local" accesskey="3" title="Local timeline (3)">local</a> - <a class="nav-link" href="/timeline/twkn" accesskey="5" title="The Whole Known Netwwork (4)">twkn</a> - <a class="nav-link" href="/timeline/remote" accesskey="4" title="Remote timeline (5)">remote</a> + <a class="nav-link" href="/timeline/twkn" accesskey="4" title="The Whole Known Netwwork (4)">twkn</a> + <a class="nav-link" href="/timeline/remote" accesskey="5" title="Remote timeline (5)">remote</a> <a class="nav-link" href="/search" accesskey="6" title="Search (6)">search</a> </div> <div> - <a class="nav-link" href="/settings" target="_top" accesskey="7" title="Settings (7)">settings</a> + <a class="nav-link" href="/lists" accesskey="7" title="Lists (7)">lists</a> + <a class="nav-link" href="/settings" target="_top" accesskey="8" title="Settings (8)">settings</a> <form class="signout" action="/signout" method="post" target="_top"> <input type="hidden" name="csrf_token" value="{{$.Ctx.CSRFToken}}"> <input type="hidden" name="referrer" value="{{$.Ctx.Referrer}}"> - <input type="submit" value="signout" class="btn-link nav-link" accesskey="8" title="Signout (8)"> + <input type="submit" value="signout" class="btn-link nav-link" title="Signout"> </form> <a class="nav-link" href="/about" accesskey="9" title="About (9)">about</a> </div> diff --git a/templates/notification.tmpl b/templates/notification.tmpl index 2778ef0..f62726b 100644 --- a/templates/notification.tmpl +++ b/templates/notification.tmpl @@ -1,13 +1,13 @@ {{with .Data}} {{template "header.tmpl" (WithContext .CommonData $.Ctx)}} -<div class="notification-title-container"> +<div class="page-title-container"> <span class="page-title"> Notifications {{if and (not $.Ctx.AntiDopamineMode) (gt .UnreadCount 0)}} ({{.UnreadCount }}) {{end}} </span> - <a class="notification-refresh" href="/notifications" target="_self" accesskey="R" title="Refresh (R)">refresh</a> + <a class="page-refresh" href="/notifications" target="_self" accesskey="R" title="Refresh (R)">refresh</a> {{if .ReadID}} <form class="notification-read" action="/notifications/read?max_id={{.ReadID}}" method="post" target="_self"> <input type="hidden" name="csrf_token" value="{{$.Ctx.CSRFToken}}"> @@ -28,7 +28,7 @@ </div> <div class="notification-follow"> <div class="notification-info-text"> - <bdi class="status-dname"> {{EmojiFilter .Account.DisplayName .Account.Emojis}} </bdi> + <bdi class="status-dname"> {{EmojiFilter (HTML .Account.DisplayName) .Account.Emojis | Raw}} </bdi> <span class="notification-text"> followed you - <time datetime="{{FormatTimeRFC3339 .CreatedAt}}" title="{{FormatTimeRFC822 .CreatedAt}}">{{TimeSince .CreatedAt}}</time> </span> @@ -48,7 +48,7 @@ </div> <div class="notification-follow"> <div class="notification-info-text"> - <bdi class="status-dname"> {{EmojiFilter .Account.DisplayName .Account.Emojis}} </bdi> + <bdi class="status-dname"> {{EmojiFilter (HTML .Account.DisplayName) .Account.Emojis | Raw}} </bdi> <span class="notification-text"> wants to follow you - <time datetime="{{FormatTimeRFC3339 .CreatedAt}}" title="{{FormatTimeRFC822 .CreatedAt}}">{{TimeSince .CreatedAt}}</time> </span> diff --git a/templates/postform.tmpl b/templates/postform.tmpl index 35171a4..0af50fb 100644 --- a/templates/postform.tmpl +++ b/templates/postform.tmpl @@ -5,7 +5,7 @@ {{if .ReplyContext}} <input type="hidden" name="reply_to_id" value="{{.ReplyContext.InReplyToID}}" /> <input type="hidden" name="quickreply" value="{{.ReplyContext.QuickReply}}" /> - <label for="post-content" class="post-form-title"> Reply to {{.ReplyContext.InReplyToName}} </label> + <label for="post-content" class="post-form-title"> Reply to @{{.ReplyContext.InReplyToName}} </label> {{else}} <label for="post-content" class="post-form-title"> New post </label> {{end}} diff --git a/templates/requestlist.tmpl b/templates/requestlist.tmpl index 8142620..1a51e31 100644 --- a/templates/requestlist.tmpl +++ b/templates/requestlist.tmpl @@ -9,7 +9,7 @@ </div> <div class="user-list-name"> <div> - <div class="status-dname"> {{EmojiFilter .DisplayName .Emojis}} </div> + <div class="status-dname"> {{EmojiFilter (HTML .DisplayName) .Emojis | Raw}} </div> <a class="img-link" href="/user/{{.ID}}"> <div class="status-uname"> @{{.Acct}} </div> </a> diff --git a/templates/search.tmpl b/templates/search.tmpl index 7338cad..0473d4a 100644 --- a/templates/search.tmpl +++ b/templates/search.tmpl @@ -5,7 +5,7 @@ <form class="search-form" action="/search" method="GET"> <span class="post-form-field"> <label for="query"> Query </label> - <input id="query" name="q" value="{{.Q | html}}"> + <input id="query" name="q" value="{{.Q}}"> </span> <span class="post-form-field"> <label for="type"> Type </label> diff --git a/templates/settings.tmpl b/templates/settings.tmpl index 6bae9c7..ebb0458 100644 --- a/templates/settings.tmpl +++ b/templates/settings.tmpl @@ -62,6 +62,11 @@ <label for="anti-dopamine-mode"> Enable <abbr title="Remove like/retweet/unread notification count and disable like/retweet/follow notifications">anti-dopamine mode</abbr> </label> </div> <div class="settings-form-field"> + <input id="hide-unsupported-notifs" name="hide_unsupported_notifs" type="checkbox" + value="true" {{if .Settings.HideUnsupportedNotifs}}checked{{end}}> + <label for="hide-unsupported-notifs"> Hide unsupported notifications </label> + </div> + <div class="settings-form-field"> <input id="dark-mode" name="dark_mode" type="checkbox" value="true" {{if .Settings.DarkMode}}checked{{end}}> <label for="dark-mode"> Use dark theme </label> </div> diff --git a/templates/status.tmpl b/templates/status.tmpl index a7cc10d..5ada84e 100644 --- a/templates/status.tmpl +++ b/templates/status.tmpl @@ -5,7 +5,7 @@ <a class="img-link" href="/user/{{.Account.ID}}"> <img class="status-profile-img" src="{{.Account.Avatar}}" title="@{{.Account.Acct}}" alt="avatar" height="24" /> </a> - <bdi class="status-dname"> {{EmojiFilter .Account.DisplayName .Account.Emojis}} </bdi> + <bdi class="status-dname"> {{EmojiFilter (HTML .Account.DisplayName) .Account.Emojis | Raw}} </bdi> <a href="/user/{{.Account.ID}}"> <span class="status-uname"> @{{.Account.Acct}} </span> </a> @@ -23,7 +23,7 @@ </div> <div class="status"> <div class="status-name"> - <bdi class="status-dname"> {{EmojiFilter .Account.DisplayName .Account.Emojis}} </bdi> + <bdi class="status-dname"> {{EmojiFilter (HTML .Account.DisplayName) .Account.Emojis | Raw}} </bdi> <a href="/user/{{.Account.ID}}"> <span class="status-uname"> @{{.Account.Acct}} </span> </a> @@ -91,7 +91,10 @@ {{end}} </div> {{if (or .Content .SpoilerText)}} - <div class="status-content"> {{StatusContentFilter (html .SpoilerText) .Content .Emojis .Mentions}} </div> + <div class="status-content"> + {{if .SpoilerText}}{{EmojiFilter (HTML .SpoilerText) .Emojis | Raw}}<br/>{{end}} + {{StatusContentFilter .Content .Emojis .Mentions | Raw}} + </div> {{end}} {{if .MediaAttachments}} <div class="status-media-container"> @@ -100,7 +103,7 @@ {{if eq .Type "image"}} {{if $.Ctx.HideAttachments}} <a href="{{.URL}}" target="_blank"> - {{if .Description}}[{{.Description}}]{{else}}[image]{{end}} + [image{{if $s.Sensitive}}/nsfw{{end}}{{if .Description}}: {{.Description}}{{end}}] </a> {{else}} <a class="img-link" href="{{.URL}}" target="_blank" title="{{.Description}}"> @@ -114,7 +117,7 @@ {{else if eq .Type "audio"}} {{if $.Ctx.HideAttachments}} <a href="{{.URL}}" target="_blank"> - {{if .Description}}[{{.Description}}]{{else}}[audio]{{end}} + [audio{{if $s.Sensitive}}/nsfw{{end}}{{if .Description}}: {{.Description}}{{end}}] </a> {{else}} <audio class="status-audio" controls title="{{.Description}}"> @@ -126,7 +129,7 @@ {{else if eq .Type "video"}} {{if $.Ctx.HideAttachments}} <a href="{{.URL}}" target="_blank"> - {{if .Description}}[{{.Description}}]{{else}}[video]{{end}} + [video{{if $s.Sensitive}}/nsfw{{end}}{{if .Description}}: {{.Description}}{{end}}] </a> {{else}} <div class="status-video-container" title="{{.Description}}"> @@ -142,7 +145,7 @@ {{else}} <a href="{{.URL}}" target="_blank"> - {{if .Description}}[{{.Description}}]{{else}}[attachment]{{end}} + [attachment{{if $s.Sensitive}}/nsfw{{end}}{{if .Description}}: {{.Description}}{{end}}] </a> {{end}} {{end}} @@ -156,12 +159,12 @@ {{range $i, $o := .Poll.Options}} <div class="poll-option"> {{if (or $s.Poll.Expired $s.Poll.Voted)}} - <div> {{EmojiFilter (html $o.Title) $s.Emojis}} - {{$o.VotesCount}} votes </div> + <div> {{EmojiFilter (HTML $o.Title) $s.Emojis | Raw}} - {{$o.VotesCount}} votes </div> {{else}} <input type="{{if $s.Poll.Multiple}}checkbox{{else}}radio{{end}}" name="choices" id="poll-{{$s.ID}}-{{$i}}" value="{{$i}}"> <label for="poll-{{$s.ID}}-{{$i}}"> - {{EmojiFilter (html $o.Title) $s.Emojis}} + {{EmojiFilter (HTML $o.Title) $s.Emojis | Raw}} </label> {{end}} </div> @@ -227,8 +230,8 @@ <div class="status-action status-action-last"> <a class="status-time" href="{{if not .ShowReplies}}/thread/{{.ID}}{{end}}#status-{{.ID}}" {{if $.Ctx.ThreadInNewTab}}target="_blank"{{end}}> - <time datetime="{{FormatTimeRFC3339 .CreatedAt}}" title="{{FormatTimeRFC822 .CreatedAt}}"> - {{TimeSince .CreatedAt}} + <time datetime="{{FormatTimeRFC3339 .CreatedAt.Time}}" title="{{FormatTimeRFC822 .CreatedAt.Time}}"> + {{TimeSince .CreatedAt.Time}} </time> </a> </div> diff --git a/templates/thread.tmpl b/templates/thread.tmpl index bb9f14f..d6a1c7d 100644 --- a/templates/thread.tmpl +++ b/templates/thread.tmpl @@ -1,8 +1,8 @@ {{with $s := .Data}} {{template "header.tmpl" (WithContext .CommonData $.Ctx)}} -<div class="notification-title-container"> +<div class="page-title-container"> <span class="page-title"> Thread </span> - <a class="notification-refresh" href="{{$.Ctx.Referrer}}" accesskey="T" title="Refresh (T)">refresh</a> + <a class="page-refresh" href="{{$.Ctx.Referrer}}" accesskey="T" title="Refresh (T)">refresh</a> </div> {{range .Statuses}} diff --git a/templates/timeline.tmpl b/templates/timeline.tmpl index bde050a..38659dc 100644 --- a/templates/timeline.tmpl +++ b/templates/timeline.tmpl @@ -1,6 +1,9 @@ {{with .Data}} {{template "header.tmpl" (WithContext .CommonData $.Ctx)}} -<div class="page-title"> {{.Title}} </div> +<div class="page-title-container"> + <span class="page-title"> {{.Title}} </span> + <a class="page-refresh" href="{{$.Ctx.Referrer}}" accesskey="T" title="Refresh (T)">refresh</a> +</div> {{if eq .Type "remote"}} <form class="search-form" action="/timeline/remote" method="GET"> diff --git a/templates/user.tmpl b/templates/user.tmpl index c7b3164..5342efe 100644 --- a/templates/user.tmpl +++ b/templates/user.tmpl @@ -11,7 +11,7 @@ </div> <div class="user-profile-details-container"> <div> - <bdi class="status-dname"> {{EmojiFilter .User.DisplayName .User.Emojis}} </bdi> + <bdi class="status-dname"> {{EmojiFilter (HTML .User.DisplayName) .User.Emojis | Raw}} </bdi> <span class="status-uname"> @{{.User.Acct}} </span> <a class="remote-link" href="{{.User.URL}}" target="_blank" title="remote profile"> source @@ -20,6 +20,7 @@ {{if not .IsCurrent}} <div> <span> {{if .User.Pleroma.Relationship.FollowedBy}} follows you - {{end}} </span> + {{if .User.Pleroma.Relationship.BlockedBy}} blocks you - {{end}} {{if .User.Pleroma.Relationship.Following}} <form class="d-inline" action="/unfollow/{{.User.ID}}" method="post"> <input type="hidden" name="csrf_token" value="{{$.Ctx.CSRFToken}}"> @@ -83,6 +84,12 @@ <input type="hidden" name="referrer" value="{{$.Ctx.Referrer}}"> <input type="submit" value="mute" class="btn-link"> </form> + - + <form class="d-inline" action="/mute/{{.User.ID}}?notifications=false" method="post"> + <input type="hidden" name="csrf_token" value="{{$.Ctx.CSRFToken}}"> + <input type="hidden" name="referrer" value="{{$.Ctx.Referrer}}"> + <input type="submit" value="mute (keep notifications)" class="btn-link"> + </form> {{end}} {{if .User.Pleroma.Relationship.Following}} - @@ -123,11 +130,15 @@ </div> </div> <div class="user-profile-decription"> - {{EmojiFilter .User.Note .User.Emojis}} + {{EmojiFilter .User.Note .User.Emojis | Raw}} </div> - {{if .User.Fields}}{{range .User.Fields}} - <div>{{.Name}} - {{.Value}}</div> - {{end}}{{end}} + {{if .User.Fields}} + <div class="user-fields"> + {{range .User.Fields}} + <div>{{.Name}} - {{.Value | Raw}}</div> + {{end}} + </div> + {{end}} </div> </div> diff --git a/templates/userlist.tmpl b/templates/userlist.tmpl index 3f75085..f206397 100644 --- a/templates/userlist.tmpl +++ b/templates/userlist.tmpl @@ -1,19 +1,7 @@ {{with .Data}} <div> {{range .}} - <div class="user-list-item"> - <div class="user-list-profile-img"> - <a class="img-link" href="/user/{{.ID}}"> - <img class="status-profile-img" src="{{.Avatar}}" title="@{{.Acct}}" alt="avatar" height="48" /> - </a> - </div> - <div class="user-list-name"> - <div class="status-dname"> {{EmojiFilter .DisplayName .Emojis}} </div> - <a class="img-link" href="/user/{{.ID}}"> - <div class="status-uname"> @{{.Acct}} </div> - </a> - </div> - </div> + {{template "userlistitem.tmpl" (WithContext . $.Ctx)}} {{else}} <div class="no-data-found">No data found</div> {{end}} diff --git a/templates/userlistitem.tmpl b/templates/userlistitem.tmpl new file mode 100644 index 0000000..50b9d0c --- /dev/null +++ b/templates/userlistitem.tmpl @@ -0,0 +1,15 @@ +{{with .Data}} +<div class="user-list-item"> + <div class="user-list-profile-img"> + <a class="img-link" href="/user/{{.ID}}"> + <img class="status-profile-img" src="{{.Avatar}}" title="@{{.Acct}}" alt="avatar" height="48" /> + </a> + </div> + <div class="user-list-name"> + <div class="status-dname"> {{EmojiFilter (HTML .DisplayName) .Emojis | Raw}} </div> + <a class="img-link" href="/user/{{.ID}}"> + <div class="status-uname"> @{{.Acct}} </div> + </a> + </div> +</div> +{{end}} diff --git a/templates/usersearch.tmpl b/templates/usersearch.tmpl index ee84143..78fa7b8 100644 --- a/templates/usersearch.tmpl +++ b/templates/usersearch.tmpl @@ -1,11 +1,11 @@ {{with .Data}} {{template "header.tmpl" (WithContext .CommonData $.Ctx)}} -<div class="page-title"> Search {{EmojiFilter .User.DisplayName .User.Emojis}}'s statuses </div> +<div class="page-title"> Search {{EmojiFilter (HTML .User.DisplayName) .User.Emojis | Raw}}'s statuses </div> <form class="search-form" action="/usersearch/{{.User.ID}}" method="GET"> - <span class="post-form-field> + <span class="post-form-field"> <label for="query"> Query </label> - <input id="query" name="q" value="{{.Q | html}}"> + <input id="query" name="q" value="{{.Q}}"> </span> <button type="submit"> Search </button> </form> diff --git a/util/getopt.go b/util/getopt.go deleted file mode 100644 index 10926a8..0000000 --- a/util/getopt.go +++ /dev/null @@ -1,122 +0,0 @@ -/* -Copyright 2019 Drew DeVault <sir@cmpwn.com> - -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: - -1. Redistributions of source code must retain the above copyright notice, this -list of conditions and the following disclaimer. - -2. Redistributions in binary form must reproduce the above copyright notice, -this list of conditions and the following disclaimer in the documentation -and/or other materials provided with the distribution. - -3. Neither the name of the copyright holder nor the names of its contributors -may be used to endorse or promote products derived from this software without -specific prior written permission. - -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND -ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED -WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -*/ - -package util - -import ( - "fmt" - "os" -) - -// In the case of "-o example", Option is 'o' and "example" is Value. For -// options which do not take an argument, Value is "". -type Option struct { - Option rune - Value string -} - -// This is returned when an unknown option is found in argv, but not in the -// option spec. -type UnknownOptionError rune - -func (e UnknownOptionError) Error() string { - return fmt.Sprintf("%s: unknown option -%c", os.Args[0], rune(e)) -} - -// This is returned when an option with a mandatory argument is missing that -// argument. -type MissingOptionError rune - -func (e MissingOptionError) Error() string { - return fmt.Sprintf("%s: expected argument for -%c", os.Args[0], rune(e)) -} - -// Getopts implements a POSIX-compatible options interface. -// -// Returns a slice of options and the index of the first non-option argument. -// -// If an error is returned, you must print it to stderr to be POSIX complaint. -func Getopts(argv []string, spec string) ([]Option, int, error) { - optmap := make(map[rune]bool) - runes := []rune(spec) - for i, rn := range spec { - if rn == ':' { - if i == 0 { - continue - } - optmap[runes[i-1]] = true - } else { - optmap[rn] = false - } - } - - var ( - i int - opts []Option - ) - for i = 1; i < len(argv); i++ { - arg := argv[i] - runes = []rune(arg) - if len(arg) == 0 || arg == "-" { - break - } - if arg[0] != '-' { - break - } - if arg == "--" { - i++ - break - } - for j, opt := range runes[1:] { - if optopt, ok := optmap[opt]; !ok { - opts = append(opts, Option{'?', ""}) - return opts, i, UnknownOptionError(opt) - } else if optopt { - if j+1 < len(runes)-1 { - opts = append(opts, Option{opt, string(runes[j+2:])}) - break - } else { - if i+1 >= len(argv) { - if len(spec) >= 1 && spec[0] == ':' { - opts = append(opts, Option{':', string(opt)}) - } else { - return opts, i, MissingOptionError(opt) - } - } else { - opts = append(opts, Option{opt, argv[i+1]}) - i++ - } - } - } else { - opts = append(opts, Option{opt, ""}) - } - } - } - return opts, i, nil -} diff --git a/util/rand.go b/util/rand.go index 1e4ec95..90e66a5 100644 --- a/util/rand.go +++ b/util/rand.go @@ -2,24 +2,18 @@ package util import ( "crypto/rand" - "math/big" + "encoding/base64" ) -var ( - runes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ1234567890") - runes_length = len(runes) -) +var enc = base64.URLEncoding func NewRandID(n int) (string, error) { - data := make([]rune, n) - for i := range data { - num, err := rand.Int(rand.Reader, big.NewInt(int64(runes_length))) - if err != nil { - return "", err - } - data[i] = runes[num.Int64()] + data := make([]byte, enc.DecodedLen(n)) + _, err := rand.Read(data) + if err != nil { + return "", err } - return string(data), nil + return enc.EncodeToString(data), nil } func NewSessionID() (string, error) { |