Add support for Filter and Lists (#1059)

Co-authored-by: James Mills <prologic@shortcircuit.net.au>
Reviewed-on: #1059
pull/1075/head
James Mills 2 weeks ago
parent c121f6310b
commit 760df68c78
  1. 79
      internal/api.go
  2. 5
      internal/bitcask_store.go
  3. 237
      internal/cache.go
  4. 6
      internal/context.go
  5. 2
      internal/conversation_handler.go
  6. 10
      internal/features.go
  7. 6
      internal/follow_handlers.go
  8. 19
      internal/langs/active.en.toml
  9. 64
      internal/langs/active.zh-CN.toml
  10. 64
      internal/langs/active.zh-TW.toml
  11. 18
      internal/langs/translate.zh-CN.toml
  12. 18
      internal/langs/translate.zh-TW.toml
  13. 27
      internal/lists_handler.go
  14. 2
      internal/post_handler.go
  15. 2
      internal/search_handler.go
  16. 3
      internal/server.go
  17. 2
      internal/store.go
  18. 29
      internal/templates.go
  19. 4
      internal/theme/static/css/02-tabler-icons.css
  20. 26
      internal/theme/static/css/99-yarn.css
  21. BIN
      internal/theme/static/css/tabler-icons.woff
  22. 4
      internal/theme/templates/discover.html
  23. 124
      internal/theme/templates/partials.html
  24. 2
      internal/theme/templates/timeline.html
  25. 6
      internal/utils.go
  26. 31
      internal/view_handlers.go
  27. 1
      tools/pods.txt

@ -5,9 +5,11 @@ package internal
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
@ -22,6 +24,7 @@ import (
"github.com/vcraescu/go-paginator"
"github.com/vcraescu/go-paginator/adapter"
"git.mills.io/prologic/bitcask"
"git.mills.io/yarnsocial/yarn/internal/passwords"
"go.yarn.social/types"
)
@ -110,6 +113,7 @@ func (a *API) initRoutes() {
// Debugging Endpoints
router.GET("/debug/websub", a.isAuthorized(a.DebugWebSubEndpoint()))
router.GET("/debug/cache", a.isAuthorized(a.DebugCacheEndpoint()))
router.GET("/debug/db", a.isAuthorized(a.DebugDBEndpoint()))
// Support / Report endpoints
router.POST("/support", a.isAuthorized(a.SupportEndpoint()))
@ -437,7 +441,7 @@ func (a *API) PostEndpoint() httprouter.Handle {
a.cache.FetchFeeds(a.config, a.archive, sources, nil)
// Re-populate/Warm cache for User
a.cache.GetByUser(user, true)
a.cache.GetByUser(user, true, FilterNoOp)
// PostResponse
w.Header().Set("Content-Type", "application/json")
@ -524,7 +528,7 @@ func (a *API) SyncEndpoint() httprouter.Handle {
a.cache.FetchFeeds(a.config, a.archive, sources, nil)
// Re-populate/Warm cache for User
a.cache.GetByUser(user, true)
a.cache.GetByUser(user, true, FilterNoOp)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(body)
@ -543,7 +547,7 @@ func (a *API) TimelineEndpoint() httprouter.Handle {
return
}
twts := a.cache.GetByUser(user, false)
twts := a.cache.GetByUser(user, false, FilterNoOp)
var pagedTwts types.Twts
@ -589,7 +593,7 @@ func (a *API) DiscoverEndpoint() httprouter.Handle {
return
}
twts := a.cache.GetByUserView(loggedInUser, discoverViewKey, false)
twts := a.cache.GetByUserView(loggedInUser, discoverViewKey, false, FilterNoOp)
var pagedTwts types.Twts
@ -702,7 +706,7 @@ func (a *API) FollowEndpoint() httprouter.Handle {
return
}
a.cache.GetByUser(user, true)
a.cache.GetByUser(user, true, FilterNoOp)
// No real response
w.Header().Set("Content-Type", "application/json")
@ -747,7 +751,7 @@ func (a *API) UnfollowEndpoint() httprouter.Handle {
return
}
a.cache.GetByUser(user, true)
a.cache.GetByUser(user, true, FilterNoOp)
// No real response
w.Header().Set("Content-Type", "application/json")
@ -1131,7 +1135,7 @@ func (a *API) ConversationEndpoint() httprouter.Handle {
return
}
twts := a.cache.GetByUserView(loggedInUser, fmt.Sprintf("subject:(#%s)", hash), false)[:]
twts := a.cache.GetByUserView(loggedInUser, fmt.Sprintf("subject:(#%s)", hash), false, FilterNoOp)[:]
if !inCache {
twts = append(twts, twt)
}
@ -1385,7 +1389,7 @@ func (a *API) MuteEndpoint() httprouter.Handle {
user.Mute(nick, url)
a.cache.GetByUser(user, true)
a.cache.GetByUser(user, true, FilterNoOp)
if err := a.db.SetUser(user.Username, user); err != nil {
log.WithError(err).Error("error updating user object")
@ -1421,7 +1425,7 @@ func (a *API) UnmuteEndpoint() httprouter.Handle {
user.Unmute(nick)
a.cache.GetByUser(user, true)
a.cache.GetByUser(user, true, FilterNoOp)
if err := a.db.SetUser(user.Username, user); err != nil {
log.WithError(err).Error("error updating user object")
@ -1559,3 +1563,60 @@ func (a *API) DebugCacheEndpoint() httprouter.Handle {
_, _ = w.Write(data)
}
}
// DebugDBEndpoint ...
func (a *API) DebugDBEndpoint() httprouter.Handle {
isAdminUser := IsAdminUserFactory(a.config)
type kvPair struct {
Key string `json:"key"`
Value string `json:"value"`
}
exportKey := func(db *bitcask.Bitcask, w io.Writer) func(key []byte) error {
return func(key []byte) error {
value, err := db.Get(key)
if err != nil {
return fmt.Errorf("errr reading key %s: %w", key, err)
}
kv := kvPair{
Key: base64.StdEncoding.EncodeToString([]byte(key)),
Value: base64.StdEncoding.EncodeToString(value),
}
data, err := json.Marshal(&kv)
if err != nil {
return fmt.Errorf("error serializing key %s: %w", key, err)
}
if n, err := w.Write(data); err != nil || n != len(data) {
if err == nil && n != len(data) {
err = fmt.Errorf("error not all data written %d/%d", n, len(data))
}
return fmt.Errorf("error writing key %s: %w", key, err)
}
if _, err := w.Write([]byte("\n")); err != nil {
return fmt.Errorf("error writing newline")
}
return nil
}
}
return func(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
user := r.Context().Value(UserContextKey).(*User)
if !isAdminUser(user) {
http.Error(w, "Forbidden", http.StatusForbidden)
return
}
if err := a.db.DB().Fold(exportKey(a.db.DB(), w)); err != nil {
log.WithError(err).Error("error dumping database")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
}
}

@ -60,6 +60,11 @@ func (bs *BitcaskStore) scanKeys(prefix string) (keys [][]byte, err error) {
return
}
// DB ...
func (bs *BitcaskStore) DB() *bitcask.Bitcask {
return bs.db
}
// Sync ...
func (bs *BitcaskStore) Sync() error {
return bs.db.Sync()

@ -52,6 +52,9 @@ var (
// FilterFunc ...
type FilterFunc func(twt types.Twt) bool
// FilterFuncFactory ...
type FilterFuncFactory func(c *Cache, u *User) FilterFunc
// GroupFunc ...
type GroupFunc func(twt types.Twt) []string
@ -73,17 +76,104 @@ func FilterOutFeedsAndBotsFactory(cache *Cache) FilterFunc {
}
}
func FilterByMentionFactory(u *User) FilterFunc {
func FilterNoRSS(c *Cache, u *User) FilterFunc {
return func(twt types.Twt) bool {
uri := twt.Twter().URI
var metadata url.Values
if cachedTwter := c.Twters[uri]; cachedTwter != nil {
metadata = cachedTwter.Metadata
}
return types.GetFeedType(uri, metadata) != types.FeedTypeRSS
}
}
func FilterNoBots(c *Cache, u *User) FilterFunc {
return func(twt types.Twt) bool {
uri := twt.Twter().URI
var metadata url.Values
if cachedTwter := c.Twters[uri]; cachedTwter != nil {
metadata = cachedTwter.Metadata
}
return types.GetFeedType(uri, metadata) != types.FeedTypeBot
}
}
func FilterMentionsMe(c *Cache, u *User) FilterFunc {
return func(twt types.Twt) bool {
if u == nil || u.IsZero() {
return false
}
for _, mention := range twt.Mentions() {
if u.Is(mention.Twter().URI) {
return true
}
for _, feed := range u.Feeds {
if strings.Replace(u.URL, fmt.Sprintf("/user/%s/twtxt.txt", u.Username), fmt.Sprintf("/user/%s/twtxt.twt", feed), 1) == NormalizeURL(mention.Twter().URI) {
return true
}
}
}
return false
}
}
func FilterExcludeMe(c *Cache, u *User) FilterFunc {
return func(twt types.Twt) bool {
return !u.Is(twt.Twter().URI)
}
}
func FilterLocalOnly(c *Cache, u *User) FilterFunc {
isLocal := IsLocalURLFactory(c.conf)
return func(twt types.Twt) bool {
return isLocal(twt.Twter().URI)
}
}
func FilterNoReplies(c *Cache, u *User) FilterFunc {
return func(twt types.Twt) bool {
// XXX: Largely borrowed from GetConvLength in utils.go
// TODO: Refactor for better reuse...
subject := twt.Subject().String()
if subject == "" {
return false
}
hash := ExtractHashFromSubject(subject)
if _, ok := c.lookup(hash); !ok {
return false
}
return len(c.getByUserView(u, fmt.Sprintf("subject:%s", subject), false, FilterNoOp)) == 1
}
}
func FilterNoOp(c *Cache, u *User) FilterFunc {
return func(twt types.Twt) bool {
return true
}
}
func GetFilterFuncFactories(fs []string) (filters []FilterFuncFactory) {
for _, f := range fs {
switch strings.ToLower(f) {
case "mentionsme":
filters = append(filters, FilterMentionsMe)
case "excludeme":
filters = append(filters, FilterExcludeMe)
case "localonly":
filters = append(filters, FilterLocalOnly)
case "noreplies":
filters = append(filters, FilterNoReplies)
case "norss":
filters = append(filters, FilterNoRSS)
case "nobots":
filters = append(filters, FilterNoBots)
}
}
return
}
func GroupBySubject(twt types.Twt) []string {
subject := strings.ToLower(twt.Subject().String())
if subject == "" {
@ -105,9 +195,18 @@ func GroupByTag(twt types.Twt) (res []string) {
return
}
func FilterTwtsBy(twts types.Twts, f FilterFunc) (res types.Twts) {
func TwtMatchesAll(twt types.Twt, filters []FilterFunc) bool {
for _, f := range filters {
if !f(twt) {
return false
}
}
return true
}
func FilterTwtsBy(twts types.Twts, filters ...FilterFunc) (res types.Twts) {
for _, twt := range twts {
if f(twt) {
if TwtMatchesAll(twt, filters) {
res = append(res, twt)
}
}
@ -1225,11 +1324,7 @@ func (cache *Cache) FetchFeeds(conf *Config, archive Archiver, feeds types.Fetch
cache.Refresh()
}
// Lookup ...
func (cache *Cache) Lookup(hash string) (types.Twt, bool) {
cache.mu.RLock()
defer cache.mu.RUnlock()
func (cache *Cache) lookup(hash string) (types.Twt, bool) {
twt, ok := cache.Map[hash]
if ok {
return twt, true
@ -1237,6 +1332,14 @@ func (cache *Cache) Lookup(hash string) (types.Twt, bool) {
return types.NilTwt, false
}
// Lookup ...
func (cache *Cache) Lookup(hash string) (types.Twt, bool) {
cache.mu.RLock()
defer cache.mu.RUnlock()
return cache.lookup(hash)
}
func (cache *Cache) FeedCount() int {
cache.mu.RLock()
defer cache.mu.RUnlock()
@ -1695,8 +1798,8 @@ func (cache *Cache) GetAll(refresh bool) types.Twts {
return nil
}
func (cache *Cache) FilterBy(f FilterFunc) types.Twts {
return FilterTwtsBy(cache.GetAll(false), f)
func (cache *Cache) FilterBy(filters ...FilterFunc) types.Twts {
return FilterTwtsBy(cache.GetAll(false), filters...)
}
func (cache *Cache) GroupBy(g GroupFunc) (res map[string]types.Twts) {
@ -1704,6 +1807,8 @@ func (cache *Cache) GroupBy(g GroupFunc) (res map[string]types.Twts) {
}
// GetMentions ...
// XXX: To be removed once FeatureFilterAndLists is promoted
// TODO: Remove my when FeatureFilterAndLists is the default
func (cache *Cache) GetMentions(u *User, refresh bool) types.Twts {
key := fmt.Sprintf("mentions:%s", u.Username)
@ -1715,7 +1820,7 @@ func (cache *Cache) GetMentions(u *User, refresh bool) types.Twts {
return cached.GetTwts()
}
mentions := cache.FilterBy(FilterByMentionFactory(u))
mentions := cache.FilterBy(FilterMentionsMe(cache, u))
twts := cache.filterTwts(u, mentions)
sort.Sort(twts)
@ -1752,34 +1857,48 @@ func (cache *Cache) GetOrSetCachedFeed(url string) *Cached {
return cached
}
// GetByView ...
func (cache *Cache) GetByView(key string) types.Twts {
cache.mu.RLock()
func (cache *Cache) getByView(key string, fffs ...FilterFuncFactory) types.Twts {
cached, ok := cache.Views[key]
cache.mu.RUnlock()
if ok {
if cache.conf.Features.IsEnabled(FeatureFilterAndLists) {
var ffs []FilterFunc
for _, fff := range fffs {
ffs = append(ffs, fff(cache, nil))
}
return FilterTwtsBy(cached.GetTwts(), ffs...)
}
return cached.GetTwts()
}
return nil
}
// GetByUser ...
func (cache *Cache) GetByUser(u *User, refresh bool) types.Twts {
// GetByView ...
func (cache *Cache) GetByView(key string) types.Twts {
cache.mu.RLock()
defer cache.mu.RUnlock()
return cache.getByView(key)
}
func (cache *Cache) getByUser(u *User, refresh bool, fffs ...FilterFuncFactory) types.Twts {
key := fmt.Sprintf("user:%s", u.Username)
cache.mu.RLock()
cached, ok := cache.Views[key]
cache.mu.RUnlock()
if ok && !refresh {
if cache.conf.Features.IsEnabled(FeatureFilterAndLists) {
var ffs []FilterFunc
for _, fff := range fffs {
ffs = append(ffs, fff(cache, u))
}
return FilterTwtsBy(cached.GetTwts(), ffs...)
}
return cached.GetTwts()
}
var twts types.Twts
for feed := range u.Sources() {
twts = append(twts, cache.GetByURL(feed.URL)...)
twts = append(twts, cache.getByURL(feed.URL)...)
}
twts = cache.filterTwts(u, twts)
sort.Sort(twts)
@ -1799,51 +1918,85 @@ func (cache *Cache) GetByUser(u *User, refresh bool) types.Twts {
twts = yarns.AsTwts()
}
cache.mu.Lock()
cache.Views[key] = NewCachedTwts(twts, "")
cache.mu.Unlock()
if cache.conf.Features.IsEnabled(FeatureFilterAndLists) {
var ffs []FilterFunc
for _, fff := range fffs {
ffs = append(ffs, fff(cache, u))
}
return FilterTwtsBy(twts, ffs...)
}
return twts
}
// GetByUserView ...
func (cache *Cache) GetByUserView(u *User, view string, refresh bool) types.Twts {
// GetByUser ...
func (cache *Cache) GetByUser(u *User, refresh bool, fffs ...FilterFuncFactory) types.Twts {
cache.mu.Lock()
defer cache.mu.Unlock()
return cache.getByUser(u, refresh, fffs...)
}
func (cache *Cache) getByUserView(u *User, view string, refresh bool, fffs ...FilterFuncFactory) types.Twts {
if u == nil || u.Username == "" {
// TODO: Cache anonymojs views?
return cache.filterTwts(nil, cache.GetByView(view))
return cache.filterTwts(nil, cache.getByView(view, fffs...))
}
key := fmt.Sprintf("%s:%s", u.Username, view)
cache.mu.RLock()
cached, ok := cache.Views[key]
cache.mu.RUnlock()
if ok && !refresh {
if cache.conf.Features.IsEnabled(FeatureFilterAndLists) {
var ffs []FilterFunc
for _, fff := range fffs {
ffs = append(ffs, fff(cache, u))
}
return FilterTwtsBy(cached.GetTwts(), ffs...)
}
return cached.GetTwts()
}
twts := cache.filterTwts(u, cache.GetByView(view))
twts := cache.filterTwts(u, cache.getByView(view))
sort.Sort(twts)
cache.mu.Lock()
cache.Views[key] = NewCachedTwts(twts, "")
cache.mu.Unlock()
if cache.conf.Features.IsEnabled(FeatureFilterAndLists) {
var ffs []FilterFunc
for _, fff := range fffs {
ffs = append(ffs, fff(cache, u))
}
return FilterTwtsBy(twts, ffs...)
}
return twts
}
// GetByURL ...
func (cache *Cache) GetByURL(url string) types.Twts {
cache.mu.RLock()
defer cache.mu.RUnlock()
// GetByUserView ...
func (cache *Cache) GetByUserView(u *User, view string, refresh bool, fffs ...FilterFuncFactory) types.Twts {
cache.mu.Lock()
defer cache.mu.Unlock()
return cache.getByUserView(u, view, refresh, fffs...)
}
func (cache *Cache) getByURL(url string) types.Twts {
if cached, ok := cache.Feeds[url]; ok {
return cached.GetTwts()
}
return types.Twts{}
}
// GetByURL ...
func (cache *Cache) GetByURL(url string) types.Twts {
cache.mu.RLock()
defer cache.mu.RUnlock()
return cache.getByURL(url)
}
// FindTwter locates a valid cached Twter by searching the Cache for previously
// fetched feeds and their Twter(s) using basic string matching
// TODO: Add Fuzzy matching?
@ -1896,7 +2049,19 @@ func (cache *Cache) GetTwter(uri string) *types.Twter {
func (cache *Cache) SetTwter(uri string, twter *types.Twter) {
cache.mu.Lock()
defer cache.mu.Unlock()
cache.Twters[uri] = twter
// Only cache a Twter iif the HashingURI (the first `# url = ` value) matchces the Fetched URI
// Otherwise we assume the Fetched Feed URI is either wrong or is an archived feed.
// This can occur in one of two ways:
// - We fetched an archived feed so the `Twter.HashingURI` (first `# url =` value) will be different to the Fetched URI
// - We fetched a feed on a different protocol, e.g: `https://` when `https://` is used as the Twter.HashingURI or similar
if cache.conf.Features.IsEnabled(FeatureFixCachedTwtersForArchivedFeeds) {
if uri == twter.HashingURI {
cache.Twters[uri] = twter
}
} else {
cache.Twters[uri] = twter
}
}
// DeleteUserViews ...

@ -47,7 +47,8 @@ type Meta struct {
}
type Context struct {
Debug bool
Debug bool
Request *http.Request
Logo template.HTML
CSS template.CSS
@ -262,7 +263,8 @@ func NewContext(s *Server, req *http.Request) *Context {
// context
ctx := &Context{
Debug: conf.Debug,
Debug: conf.Debug,
Request: req,
Logo: logo,
CSS: css,

@ -98,7 +98,7 @@ func (s *Server) ConversationHandler() httprouter.Handle {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Link", fmt.Sprintf(`<%s/webmention>; rel="webmention"`, s.config.BaseURL))
twts := s.cache.GetByUserView(ctx.User, fmt.Sprintf("subject:(#%s)", hash), false)[:]
twts := s.cache.GetByUserView(ctx.User, fmt.Sprintf("subject:(#%s)", hash), false, FilterNoOp)[:]
if !inCache {
twts = append(twts, twt)
}

@ -22,6 +22,8 @@ const (
FeatureMovingAverageFeedRefresh
FeatureWebSub
FeatureFixMovedTwters
FeatureFilterAndLists
FeatureFixCachedTwtersForArchivedFeeds
)
// Interface guards
@ -42,6 +44,10 @@ func (f FeatureType) String() string {
return "websub"
case FeatureFixMovedTwters:
return "fix_moved_twters"
case FeatureFilterAndLists:
return "filter_and_lists"
case FeatureFixCachedTwtersForArchivedFeeds:
return "fix_cached_twters_for_archived_feeds"
default:
return "invalid_feature"
}
@ -78,6 +84,10 @@ func FeatureFromString(s string) (FeatureType, error) {
return FeatureWebSub, nil
case "fix_moved_twters":
return FeatureFixMovedTwters, nil
case "filter_and_lists":
return FeatureFilterAndLists, nil
case "fix_cached_twters_for_archived_feeds":
return FeatureFixCachedTwtersForArchivedFeeds, nil
default:
fs := fmt.Sprintf("available features: %s", strings.Join(AvailableFeatures(), " "))
return FeatureInvalid, fmt.Errorf("Error unrecognised feature: %s (%s)", s, fs)

@ -58,7 +58,7 @@ func (s *Server) FollowHandler() httprouter.Handle {
return
}
s.cache.GetByUser(ctx.User, true)
s.cache.GetByUser(ctx.User, true, FilterNoOp)
ctx.Error = false
ctx.Message = s.tr(ctx, "MsgFollowUserSuccess", trdata)
@ -122,7 +122,7 @@ func (s *Server) ImportHandler() httprouter.Handle {
return
}
s.cache.GetByUser(ctx.User, true)
s.cache.GetByUser(ctx.User, true, FilterNoOp)
ctx.Error = false
ctx.Message = fmt.Sprintf("Successfully imported %d feeds", imported)
@ -169,7 +169,7 @@ func (s *Server) UnfollowHandler() httprouter.Handle {
return
}
s.cache.GetByUser(ctx.User, true)
s.cache.GetByUser(ctx.User, true, FilterNoOp)
ctx.Error = false
ctx.Message = s.tr(ctx, "MsgUnfollowSuccess", trdata)

@ -73,9 +73,10 @@ ErrorInvalidFeedName = "Invalid feed name: {{ .Error }}"
ErrorInvalidPassword = "Invalid password! Hint: Reset your password?"
ErrorInvalidToken = "Invalid token"
ErrorInvalidUsername = "Invalid username! Hint: Register an account?"
ErrorLoadingDiscover = "An error occurred while loading the discover"
ErrorLoadingDiscover = "An error occurred while loading discover"
ErrorLoadingFeed = "Error loading feed"
ErrorLoadingFeeds = "An error occurred while loading feeds"
ErrorLoadingLists = "An error occurred while loading lists"
ErrorLoadingMentions = "An error occurred while loading mentions"
ErrorLoadingPage = "Error loading page! Please contact support."
ErrorLoadingProfile = "Error loading profile"
@ -124,6 +125,15 @@ FeedsMyFeedsTitle = "My Feeds"
FeedsNoFeedsSummary = "You do not have any feeds. <a href=\"#create\">Create</a> one?"
FeedsSummary = "Create a new local feed on this pod"
FeedsTitle = "Create Feed"
FilterNavLabel = "Filters"
FilterTitle_clear = "Clear"
FilterTitle_excludeme = "Exclude Me"
FilterTitle_localonly = "On {{ .InstanceName }}"
FilterTitle_mentionsme = "Mentions Me"
FilterTitle_nobots = "Exclude Bots"
FilterTitle_noop = "All"
FilterTitle_noreplies = "No Replies"
FilterTitle_norss = "Exclude Feeds"
FollowExternal = "Details on followers are not available on external feeds."
FollowFormFollow = "Follow"
FollowFormNickname = "Nickname for the feed"
@ -305,6 +315,7 @@ NavDiscover = "Discover"
NavFeeds = "Feeds"
NavFollow = "Follow"
NavJoin = "Join"
NavLists = "Lists"
NavLogin = "Login"
NavLogout = "Logout"
NavLogoutConfirm = "Are you sure you want logout?"
@ -320,6 +331,7 @@ PageExternalFollowingTitle = "{{ .DomainNick }} is following"
PageExternalProfileTitle = "External profile for @<{{ .Nick }} {{ .URL }}>"
PageFeedsTitle = "Feeds"
PageFollowTitle = "Follow a new feed"
PageListsTitle = "Lists"
PageLocalTimelineTitle = "Local timeline"
PageManageFeedTitle = "Manage feed {{ .Feed }}"
PageMentionsTitle = "Mentions"
@ -353,7 +365,7 @@ ProfileLinks = "User Links"
ProfileMuteLinkTitle = "Mute"
ProfileMuteUser = "You are free to Unfollow or Mute this user or feed.&#10;Muting will also remove that user/feed's content from your view.&#10;You will no longer see content from that user/feed anywhere."
ProfileNoDescription = "No description provided."
ProfileNoTwts = "Try checking out the <a href=\"/discover\">{{ .NavDiscover }}</a> timeline to see what's happenning on the {{ .InstanceName }} pod or <a href=\"/follow\">{{ .NavFollow }}</a> a feed"
ProfileNoTwts = "Try checking out the <a href=\"/discover\">{{ .NavDiscover }}</a> to see what's happenning on the {{ .InstanceName }} pod or <a href=\"/follow\">{{ .NavFollow }}</a> a feed"
ProfileReportLinkTitle = "Report"
ProfileReportUser = "If this user/feed is violating this Pod's ({{ .InstanceName }})&#10;community guidelines as set out in the Abuse Policy,&#10;please report them immediately!"
ProfileToolTitle = "Mute / Report User"
@ -455,11 +467,11 @@ ThemeDark = "SimpleCSS Dark"
ThemeDarkClassic = "Yarn.social Dark"
ThemeLight = "SimpleCSS Light"
ThemeLightClassic = "Yarn.social Light"
ToolbarButtonBlockquote = "Blockquote"
ToolbarButtonBold = "Bold"
ToolbarButtonCode = "Code"
ToolbarButtonImage = "Image Link"
ToolbarButtonItalic = "Italics"
ToolbarButtonBlockquote = "Blockquote"
ToolbarButtonLink = "URL Link"
ToolbarButtonMedia = "Upload Media"
ToolbarButtonMention = "Mention"
@ -484,3 +496,4 @@ TwtReadMore = "⤋ Read More"
TwtReplyLinkTitle = "Reply"
TwtUnmute = "Unmute Twt"
UnfollowLinkTitle = "Unfollow"
filterTitle_nobots = "Exclude Bots"

@ -299,8 +299,8 @@ hash = "sha1-c1d07ba7f57bf718dfb346b21773834cc34c7fcd"
other = "用户名无效! 提示: 注册一个账号?"
[ErrorLoadingDiscover]
hash = "sha1-6c78d0bc64a477cc75cec7a5f9c1b5800b402689"
other = "加载发现数据出错"
hash = "sha1-3434b7ad88355029b53bae00f2d21843a18dd611"
other = "An error occurred while loading discover"
[ErrorLoadingFeed]
hash = "sha1-488ffebda10d862ebc11d543467ff05e84a532cb"
@ -310,6 +310,10 @@ other = "加载 Feed 出错"
hash = "sha1-5662fba6346ae041c64d1c03359096c393fa7ed2"
other = "加载 Feeds 出错"
[ErrorLoadingLists]
hash = "sha1-796ea0ecfd7c2e7baa44d92516e9076ac5ba211f"
other = "An error occurred while loading lists"
[ErrorLoadingMentions]
hash = "sha1-c6cad6996851c5c491ed46f42993b0941046f79d"
other = "加载提及数据出错"
@ -502,6 +506,42 @@ other = "创建一个本地 Feed"
hash = "sha1-074e5c72245f23f1864a151844ef4ffd970aeca2"
other = "创建 Feed"
[FilterNavLabel]
hash = "sha1-96e578211aa295317cf257310712fa28ccd8f6c6"
other = "Filters"
[FilterTitle_clear]
hash = "sha1-719ea396ad92e01b4757ec2b93bb1e5f270f771d"
other = "Clear"
[FilterTitle_excludeme]
hash = "sha1-76555d2ada3172410d75a7a6ac0a3301acb88816"
other = "Exclude Me"
[FilterTitle_localonly]
hash = "sha1-e6b3ff2daf2888b4c82771a4dcfa15e6b7b0f172"
other = "On {{ .InstanceName }}"
[FilterTitle_mentionsme]
hash = "sha1-9a2823c6fafd912ee016cb759ba0fe4f69aeefe0"
other = "Mentions Me"
[FilterTitle_nobots]
hash = "sha1-5d060ddf4fbef919b11e2330170bd6c606658c07"
other = "Exclude Bots"
[FilterTitle_noop]
hash = "sha1-6a72085653e4c5be8c7640c868ef787cbcf063d1"
other = "All"
[FilterTitle_noreplies]
hash = "sha1-fcdd2f159ecb481c1efeb54429fa468c7434f701"
other = "No Replies"
[FilterTitle_norss]
hash = "sha1-a72f80d9c489963b0bdeecdd47663db9c9d0d355"
other = "Exclude Feeds"
[FollowExternal]
hash = "sha1-6449ded9eb3838ce1579d2fc82ed24244cabe678"
other = "Details on followers are not available on external feeds."
@ -1226,6 +1266,10 @@ other = "关注"
hash = "sha1-e0d73143de80d17e82de2e017ac156ca3b9c4e01"
other = "Join"
[NavLists]
hash = "sha1-57c9502a7d7d48fd4a86b45fefb2b163491c3ae1"
other = "Lists"
[NavLogin]
hash = "sha1-4e5a2893bdcc7d239c1db72e4c4ffbe4bea73174"
other = "登录"
@ -1286,6 +1330,10 @@ other = "Feeds"
hash = "sha1-c3954605089b8a03bc74ad154731981fe88eb87e"
other = "关注"
[PageListsTitle]
hash = "sha1-57c9502a7d7d48fd4a86b45fefb2b163491c3ae1"
other = "Lists"
[PageLocalTimelineTitle]
hash = "sha1-c69cd8d5a9c8ce344bc8d779a0b8a2710ebca482"
other = "本地动态"
@ -1419,8 +1467,8 @@ hash = "sha1-fe3f10ae47cf6f826ac900b3c510bfe6745352da"
other = "No description provided."
[ProfileNoTwts]
hash = "sha1-1e9ef392a4bff24ed048ac19da0fcd4c5348c9b1"
other = "Try checking out the <a href=\"/discover\">{{ .NavDiscover }}</a> timeline to see what's happenning on the {{ .InstanceName }} pod or <a href=\"/follow\">{{ .NavFollow }}</a> a feed"
hash = "sha1-92629eb8ece32dd6217b417b1274d2f4073d1ca3"
other = "Try checking out the <a href=\"/discover\">{{ .NavDiscover }}</a> to see what's happenning on the {{ .InstanceName }} pod or <a href=\"/follow\">{{ .NavFollow }}</a> a feed"
[ProfileReportLinkTitle]
hash = "sha1-ee45c30326b750387589752c0f75e1dd87ddc7e4"
@ -1826,6 +1874,10 @@ other = "SimpleCSS Light"
hash = "sha1-d2b3b0a0dd7776b5e7c34513c1e6e35f3b432cd3"
other = "Yarn.social Light"
[ToolbarButtonBlockquote]
hash = "sha1-4362c13406d51b403bf98c9eb4c21bbe87e3bc34"
other = "Blockquote"
[ToolbarButtonBold]
hash = "sha1-19e07430eed6d97d6d73cb4a2967b1f316520f54"
other = "Bold"
@ -1937,3 +1989,7 @@ other = "Unmute Twt"
[UnfollowLinkTitle]
hash = "sha1-e3a6fe565c7f64204f2dd182dc8b8ca9fe725d99"
other = "取消关注"
[filterTitle_nobots]
hash = "sha1-5d060ddf4fbef919b11e2330170bd6c606658c07"
other = "Exclude Bots"

@ -299,8 +299,8 @@ hash = "sha1-c1d07ba7f57bf718dfb346b21773834cc34c7fcd"
other = "用戶名無效! 提示: 註冊一個賬號?"
[ErrorLoadingDiscover]
hash = "sha1-6c78d0bc64a477cc75cec7a5f9c1b5800b402689"
other = "加載發現數據出錯"
hash = "sha1-3434b7ad88355029b53bae00f2d21843a18dd611"
other = "An error occurred while loading discover"
[ErrorLoadingFeed]
hash = "sha1-488ffebda10d862ebc11d543467ff05e84a532cb"
@ -310,6 +310,10 @@ other = "加載 Feed 出錯"
hash = "sha1-5662fba6346ae041c64d1c03359096c393fa7ed2"
other = "加載 Feeds 出錯"
[ErrorLoadingLists]
hash = "sha1-796ea0ecfd7c2e7baa44d92516e9076ac5ba211f"
other = "An error occurred while loading lists"
[ErrorLoadingMentions]
hash = "sha1-c6cad6996851c5c491ed46f42993b0941046f79d"
other = "加載提及數據出錯"
@ -502,6 +506,42 @@ other = "創建一個本地 Feed"
hash = "sha1-074e5c72245f23f1864a151844ef4ffd970aeca2"
other = "創建 Feed"
[FilterNavLabel]
hash = "sha1-96e578211aa295317cf257310712fa28ccd8f6c6"
other = "Filters"
[FilterTitle_clear]
hash = "sha1-719ea396ad92e01b4757ec2b93bb1e5f270f771d"
other = "Clear"
[FilterTitle_excludeme]
hash = "sha1-76555d2ada3172410d75a7a6ac0a3301acb88816"
other = "Exclude Me"
[FilterTitle_localonly]
hash = "sha1-e6b3ff2daf2888b4c82771a4dcfa15e6b7b0f172"
other = "On {{ .InstanceName }}"
[FilterTitle_mentionsme]
hash = "sha1-9a2823c6fafd912ee016cb759ba0fe4f69aeefe0"
other = "Mentions Me"
[FilterTitle_nobots]
hash = "sha1-5d060ddf4fbef919b11e2330170bd6c606658c07"
other = "Exclude Bots"
[FilterTitle_noop]
hash = "sha1-6a72085653e4c5be8c7640c868ef787cbcf063d1"
other = "All"
[FilterTitle_noreplies]
hash = "sha1-fcdd2f159ecb481c1efeb54429fa468c7434f701"
other = "No Replies"
[FilterTitle_norss]
hash = "sha1-a72f80d9c489963b0bdeecdd47663db9c9d0d355"
other = "Exclude Feeds"
[FollowExternal]
hash = "sha1-6449ded9eb3838ce1579d2fc82ed24244cabe678"
other = "Details on followers are not available on external feeds."
@ -1226,6 +1266,10 @@ other = "跟隨"
hash = "sha1-e0d73143de80d17e82de2e017ac156ca3b9c4e01"
other = "Join"
[NavLists]
hash = "sha1-57c9502a7d7d48fd4a86b45fefb2b163491c3ae1"
other = "Lists"
[NavLogin]
hash = "sha1-4e5a2893bdcc7d239c1db72e4c4ffbe4bea73174"
other = "登錄"
@ -1286,6 +1330,10 @@ other = "Feeds"
hash = "sha1-c3954605089b8a03bc74ad154731981fe88eb87e"
other = "跟隨"
[PageListsTitle]
hash = "sha1-57c9502a7d7d48fd4a86b45fefb2b163491c3ae1"
other = "Lists"
[PageLocalTimelineTitle]
hash = "sha1-c69cd8d5a9c8ce344bc8d779a0b8a2710ebca482"
other = "本地動態"
@ -1419,8 +1467,8 @@ hash = "sha1-fe3f10ae47cf6f826ac900b3c510bfe6745352da"
other = "No description provided."
[ProfileNoTwts]
hash = "sha1-1e9ef392a4bff24ed048ac19da0fcd4c5348c9b1"
other = "Try checking out the <a href=\"/discover\">{{ .NavDiscover }}</a> timeline to see what's happenning on the {{ .InstanceName }} pod or <a href=\"/follow\">{{ .NavFollow }}</a> a feed"
hash = "sha1-92629eb8ece32dd6217b417b1274d2f4073d1ca3"
other = "Try checking out the <a href=\"/discover\">{{ .NavDiscover }}</a> to see what's happenning on the {{ .InstanceName }} pod or <a href=\"/follow\">{{ .NavFollow }}</a> a feed"
[ProfileReportLinkTitle]
hash = "sha1-ee45c30326b750387589752c0f75e1dd87ddc7e4"
@ -1826,6 +1874,10 @@ other = "SimpleCSS Light"
hash = "sha1-d2b3b0a0dd7776b5e7c34513c1e6e35f3b432cd3"
other = "Yarn.social Light"
[ToolbarButtonBlockquote]
hash = "sha1-4362c13406d51b403bf98c9eb4c21bbe87e3bc34"
other = "Blockquote"
[ToolbarButtonBold]
hash = "sha1-19e07430eed6d97d6d73cb4a2967b1f316520f54"
other = "Bold"
@ -1937,3 +1989,7 @@ other = "Unmute Twt"
[UnfollowLinkTitle]
hash = "sha1-e3a6fe565c7f64204f2dd182dc8b8ca9fe725d99"
other = "取消跟隨"
[filterTitle_nobots]
hash = "sha1-5d060ddf4fbef919b11e2330170bd6c606658c07"
other = "Exclude Bots"

@ -1,15 +1,3 @@
[LoginFormRememberMeHelp]
hash = "sha1-49d2678b33ee0fc5fe9c7f7a94ebcc1ddc681430"
other = "Ticking this box will store your session for longer backed by a persistent storage backend instead of in-memory. This will ensure your login sessions last longer and improve your overall experience without logging you out frequently."
[LoginViaEmailAddress]
hash = "sha1-b0f08896b0b1c32dd0d8789f91ffd6b291b4f655"
other = "Send me a login link via email"
[LoginViaEmailAddressHowToContent]
hash = "sha1-e73e2ea7a7c406647b68308226c34556dffc5139"
other = "You'll receive a login link in your inbox"
[RegisterFormEmailAddressHelp]
hash = "sha1-23981a618071eb29138b4d57799ec37b10ca1884"
other = "Note that we do not actually store your email address. We store a hash, called a recovery hash. This is used for password resets and account recovery so if you forget or lose access to your Email account provided here, it will be impossible to recover your account!"
[FilterNavLabel]
hash = "sha1-96e578211aa295317cf257310712fa28ccd8f6c6"
other = "Filters"

@ -1,15 +1,3 @@
[LoginFormRememberMeHelp]
hash = "sha1-49d2678b33ee0fc5fe9c7f7a94ebcc1ddc681430"
other = "Ticking this box will store your session for longer backed by a persistent storage backend instead of in-memory. This will ensure your login sessions last longer and improve your overall experience without logging you out frequently."
[LoginViaEmailAddress]
hash = "sha1-b0f08896b0b1c32dd0d8789f91ffd6b291b4f655"
other = "Send me a login link via email"
[LoginViaEmailAddressHowToContent]
hash = "sha1-e73e2ea7a7c406647b68308226c34556dffc5139"
other = "You'll receive a login link in your inbox"
[RegisterFormEmailAddressHelp]
hash = "sha1-23981a618071eb29138b4d57799ec37b10ca1884"
other = "Note that we do not actually store your email address. We store a hash, called a recovery hash. This is used for password resets and account recovery so if you forget or lose access to your Email account provided here, it will be impossible to recover your account!"
[FilterNavLabel]
hash = "sha1-96e578211aa295317cf257310712fa28ccd8f6c6"
other = "Filters"

@ -0,0 +1,27 @@
// Copyright 2020-present Yarn.social
// SPDX-License-Identifier: AGPL-3.0-or-later
package internal
import (
"net/http"
"github.com/julienschmidt/httprouter"
)
// ListsHandler ...
func (s *Server) ListsHandler() httprouter.Handle {
return func(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
if !s.config.Features.IsEnabled(FeatureFilterAndLists) {
http.Error(w, "Not Found", http.StatusNotFound)
return
}
ctx := NewContext(s, r)
ctx.Title = "Lists"
ctx.Error = true
ctx.Message = "Error, this feature is not implemented yet... Come back later!"
s.render("error", w, ctx)
}
}

@ -157,7 +157,7 @@ func (s *Server) PostHandler() httprouter.Handle {
s.cache.InjectFeed(feedURL, twt)
// Refresh user views.
s.cache.GetByUser(ctx.User, true)
s.cache.GetByUser(ctx.User, true, FilterNoOp)
// WebMentions ...
// TODO: Use a queue here instead?

@ -43,7 +43,7 @@ func (s *Server) SearchHandler() httprouter.Handle {
}
}
twts = append(twts, s.cache.GetByUserView(ctx.User, fmt.Sprintf("tag:%s", strings.ToLower(tag)), false)...)
twts = append(twts, s.cache.GetByUserView(ctx.User, fmt.Sprintf("tag:%s", strings.ToLower(tag)), false, FilterNoOp)...)
sort.Sort(sort.Reverse(twts))
var pagedTwts types.Twts

@ -759,6 +759,9 @@ func (s *Server) initRoutes() {
s.router.GET("/", httproutermiddleware.Handler("timeline", s.TimelineHandler(), mdlw))
s.router.HEAD("/", httproutermiddleware.Handler("timeline", s.TimelineHandler(), mdlw))
s.router.GET("/lists", httproutermiddleware.Handler("lists", s.am.MustAuth(s.ListsHandler()), mdlw))
s.router.HEAD("/lists", httproutermiddleware.Handler("lists", s.am.MustAuth(s.ListsHandler()), mdlw))
s.router.GET("/robots.txt", httproutermiddleware.Handler("robots", s.RobotsHandler(), mdlw))
s.router.HEAD("/robots.txt", httproutermiddleware.Handler("robots", s.RobotsHandler(), mdlw))

@ -21,6 +21,8 @@ var (
)
type Store interface {
DB() *bitcask.Bitcask
Merge() error
Close() error
Sync() error

@ -10,6 +10,8 @@ import (
"io"
"io/fs"
"math"
"net/http"
"net/url"
"path/filepath"
"strings"
text_template "text/template"
@ -101,6 +103,33 @@ func NewTemplateManager(conf *Config, translator *Translator, cache *Cache, arch
funcMap["isFeatureEnabled"] = func(name string) bool {
return IsFeatureEnabled(conf.Features, name)
}
funcMap["hasFilter"] = func(r *http.Request, name string) bool {
return HasString(r.URL.Query()["f"], name)
}
funcMap["toggleFilter"] = func(r *http.Request, name string) string {
u, _ := url.Parse(r.URL.String())
q := u.Query()
if HasString(q["f"], name) {
v := url.Values{}
for _, x := range q["f"] {
if !strings.EqualFold(x, name) {
v.Add("f", x)
}
}
u.RawQuery = v.Encode()
} else {
q.Add("f", name)
u.RawQuery = q.Encode()
}
return u.String()
}
funcMap["clearFilters"] = func(r *http.Request) string {
u, _ := url.Parse(r.URL.String())
q := u.Query()
q.Del("f")
u.RawQuery = q.Encode()
return u.String()
}
funcMap["html"] = func(text string) template.HTML { return template.HTML(text) }
funcMap["tr"] = func(ctx *Context, msgid string, data ...interface{}) string {

@ -253,3 +253,7 @@
.ti-blockquote:before {
content: "\ee09";
}
.ti-list:before {
content: "\eb6b";
}

@ -83,6 +83,7 @@ yarn-pref {
#timelineBtn,
#discoverBtn,
#mentionsBtn,
#listsBtn,
#feedsBtn,
#settingsBtn,
#logoutBtn,
@ -96,6 +97,7 @@ yarn-pref {
#timelineBtn:hover,
#discoverBtn:hover,
#mentionsBtn:hover,
#listsBtn:hover,
#feedsBtn:hover,
#settingsBtn:hover,
#logoutBtn:hover,
@ -108,6 +110,7 @@ yarn-pref {
#timelineBtn.active,
#discoverBtn.active,
#mentionsBtn.active,
#listsBtn.active,
#feedsBtn.active,
#settingsBtn.active,
#logoutBtn.active,
@ -120,6 +123,7 @@ yarn-pref {
#timelineBtn i:after,
#discoverBtn i:after,
#mentionsBtn i:after,
#listsBtn i:after,
#feedsBtn i:after,
#settingsBtn i:after,
#logoutBtn i:after,
@ -607,6 +611,27 @@ nav.timeline-nav a {
margin-top: -0.9rem;
}
nav.filternav {
display: inline-block;
padding-bottom: 0.75rem;
}
nav.filternav ul {
margin: 0;
padding: 0;
list-style: none;
}
nav.filternav li {
display: inline-block;
padding-right: 0.25rem;
border-right: 1px solid var(--border);
}
nav.filternav a.active {
border-bottom: 2px dotted var(--accent);
}
nav.filternav li:last-of-type {
border-right: none;
}
.toolbar-nav {
margin: 0.5rem 0 -0.75rem 0;
display: flex;
@ -1461,6 +1486,7 @@ header.container {
#timelineBtn,
#discoverBtn,
#mentionsBtn,
#listsBtn,
#feedsBtn,
#settingsBtn,
#logoutBtn,

@ -0,0 +1,4 @@
{{ define "content" }}
{{ template "post" (dict "Authenticated" $.Authenticated "User" $.User "TwtPrompt" $.TwtPrompt "MaxTwtLength" $.MaxTwtLength "Reply" $.Reply "AutoFocus" true "CSRFToken" $.CSRFToken "Ctx" . "view" "timeline") }}
{{ template "feed" (dict "Authenticated" $.Authenticated "User" $.User "Profile" $.Profile "LastTwt" $.LastTwt "Pager" $.Pager "Twts" $.Twts "Ctx" . "view" "discover") }}
{{ end }}

@ -1,58 +1,58 @@
{{ define "navbar" }}
<div id="podNavigation" {{ if not .Authenticated }} class="mobNoAuth" {{end}}>
{{ if .Authenticated }}
<div id="timelineBtn" class="{{ if eq .Title "Timeline" }}active{{ end }}">
<a href="/" title="Last updated {{ .TimelineUpdatedAt | time }}">
<i class="ti ti-message-circle"></i> {{ tr . "NavTimeline" }}
</a>
<span class="timeAgo">({{ .TimelineUpdatedAt | time }})</span>
</div>