summaryrefslogtreecommitdiff
path: root/internal/filter
diff options
context:
space:
mode:
authorLibravatar kim <grufwub@gmail.com>2025-07-01 16:00:04 +0200
committerLibravatar kim <gruf@noreply.codeberg.org>2025-07-01 16:00:04 +0200
commit4f2aa792b33fdd5fb4b22dec813b3668d7190522 (patch)
tree1148a9322d04bf43c1c159df3079fb1790c5c154 /internal/filter
parent[chore] update go dependencies (#4304) (diff)
downloadgotosocial-4f2aa792b33fdd5fb4b22dec813b3668d7190522.tar.xz
[performance] add statusfilter cache to cache calculated status filtering results (#4303)
this adds another 'filter' type cache, similar to the visibility and mute caches, to cache the results of status filtering checks. for the moment this keeps all the check calls themselves within the frontend typeconversion code, but i may move this out of the typeconverter in a future PR (also removing the ErrHideStatus means of propagating a hidden status). also tweaks some of the cache invalidation hooks to not make unnecessary calls. Reviewed-on: https://codeberg.org/superseriousbusiness/gotosocial/pulls/4303 Co-authored-by: kim <grufwub@gmail.com> Co-committed-by: kim <grufwub@gmail.com>
Diffstat (limited to 'internal/filter')
-rw-r--r--internal/filter/mutes/filter.go2
-rw-r--r--internal/filter/status/api.go105
-rw-r--r--internal/filter/status/filter.go33
-rw-r--r--internal/filter/status/status.go313
-rw-r--r--internal/filter/status/text.go80
-rw-r--r--internal/filter/status/text_test.go84
6 files changed, 612 insertions, 5 deletions
diff --git a/internal/filter/mutes/filter.go b/internal/filter/mutes/filter.go
index 20adc3daf..fc5dd3362 100644
--- a/internal/filter/mutes/filter.go
+++ b/internal/filter/mutes/filter.go
@@ -41,5 +41,5 @@ const noauth = "noauth"
// given statuses or accounts are muted by a requester (user).
type Filter struct{ state *state.State }
-// NewFilter returns a new Filter interface that will use the provided database.
+// NewFilter returns a new Filter interface that will use the provided state.
func NewFilter(state *state.State) *Filter { return &Filter{state: state} }
diff --git a/internal/filter/status/api.go b/internal/filter/status/api.go
new file mode 100644
index 000000000..1d6684b59
--- /dev/null
+++ b/internal/filter/status/api.go
@@ -0,0 +1,105 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package status
+
+import (
+ "time"
+
+ apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model"
+ "code.superseriousbusiness.org/gotosocial/internal/gtserror"
+ "code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
+ "code.superseriousbusiness.org/gotosocial/internal/util"
+)
+
+// NOTE: the below functions have all been copied
+// from typeutils to prevent an import cycle. when
+// we move the filtering logic out of the converter
+// then we can safely remove these and call necessary
+// function without any worry of import cycles.
+
+func toAPIFilterV2(filter *gtsmodel.Filter) apimodel.FilterV2 {
+ apiFilterKeywords := make([]apimodel.FilterKeyword, len(filter.Keywords))
+ if len(apiFilterKeywords) != len(filter.Keywords) {
+ // bound check eliminiation compiler-hint
+ panic(gtserror.New("BCE"))
+ }
+ for i, filterKeyword := range filter.Keywords {
+ apiFilterKeywords[i] = apimodel.FilterKeyword{
+ ID: filterKeyword.ID,
+ Keyword: filterKeyword.Keyword,
+ WholeWord: util.PtrOrValue(filterKeyword.WholeWord, false),
+ }
+ }
+ apiFilterStatuses := make([]apimodel.FilterStatus, len(filter.Statuses))
+ if len(apiFilterStatuses) != len(filter.Statuses) {
+ // bound check eliminiation compiler-hint
+ panic(gtserror.New("BCE"))
+ }
+ for i, filterStatus := range filter.Statuses {
+ apiFilterStatuses[i] = apimodel.FilterStatus{
+ ID: filterStatus.ID,
+ StatusID: filterStatus.StatusID,
+ }
+ }
+ return apimodel.FilterV2{
+ ID: filter.ID,
+ Title: filter.Title,
+ Context: toAPIFilterContexts(filter),
+ ExpiresAt: toAPIFilterExpiresAt(filter.ExpiresAt),
+ FilterAction: toAPIFilterAction(filter.Action),
+ Keywords: apiFilterKeywords,
+ Statuses: apiFilterStatuses,
+ }
+}
+
+func toAPIFilterExpiresAt(expiresAt time.Time) *string {
+ if expiresAt.IsZero() {
+ return nil
+ }
+ return util.Ptr(util.FormatISO8601(expiresAt))
+}
+
+func toAPIFilterContexts(filter *gtsmodel.Filter) []apimodel.FilterContext {
+ apiContexts := make([]apimodel.FilterContext, 0, apimodel.FilterContextNumValues)
+ if filter.Contexts.Home() {
+ apiContexts = append(apiContexts, apimodel.FilterContextHome)
+ }
+ if filter.Contexts.Notifications() {
+ apiContexts = append(apiContexts, apimodel.FilterContextNotifications)
+ }
+ if filter.Contexts.Public() {
+ apiContexts = append(apiContexts, apimodel.FilterContextPublic)
+ }
+ if filter.Contexts.Thread() {
+ apiContexts = append(apiContexts, apimodel.FilterContextThread)
+ }
+ if filter.Contexts.Account() {
+ apiContexts = append(apiContexts, apimodel.FilterContextAccount)
+ }
+ return apiContexts
+}
+
+func toAPIFilterAction(m gtsmodel.FilterAction) apimodel.FilterAction {
+ switch m {
+ case gtsmodel.FilterActionWarn:
+ return apimodel.FilterActionWarn
+ case gtsmodel.FilterActionHide:
+ return apimodel.FilterActionHide
+ }
+ return apimodel.FilterActionNone
+}
diff --git a/internal/filter/status/filter.go b/internal/filter/status/filter.go
new file mode 100644
index 000000000..d9ec12934
--- /dev/null
+++ b/internal/filter/status/filter.go
@@ -0,0 +1,33 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package status
+
+import (
+ "code.superseriousbusiness.org/gotosocial/internal/state"
+)
+
+// noauth is a placeholder ID used in cache lookups
+// when there is no authorized account ID to use.
+const noauth = "noauth"
+
+// Filter packages up logic for checking whether
+// given status is muted by a given requester (user).
+type Filter struct{ state *state.State }
+
+// New returns a new Filter interface that will use the provided state.
+func NewFilter(state *state.State) *Filter { return &Filter{state} }
diff --git a/internal/filter/status/status.go b/internal/filter/status/status.go
index 1a611cdd1..5f997129d 100644
--- a/internal/filter/status/status.go
+++ b/internal/filter/status/status.go
@@ -15,12 +15,317 @@
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
-// Package status represents status filters managed by the user through the API.
package status
import (
- "errors"
+ "context"
+ "regexp"
+ "slices"
+ "time"
+
+ apimodel "code.superseriousbusiness.org/gotosocial/internal/api/model"
+ "code.superseriousbusiness.org/gotosocial/internal/cache"
+ "code.superseriousbusiness.org/gotosocial/internal/gtserror"
+ "code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
)
-// ErrHideStatus indicates that a status has been filtered and should not be returned at all.
-var ErrHideStatus = errors.New("hide status")
+// StatusFilterResultsInContext returns status filtering results, limited
+// to the given filtering context, about the given status for requester.
+// The hide flag is immediately returned if any filters match with the
+// HIDE action set, else API model filter results for the WARN action.
+func (f *Filter) StatusFilterResultsInContext(
+ ctx context.Context,
+ requester *gtsmodel.Account,
+ status *gtsmodel.Status,
+ context gtsmodel.FilterContext,
+) (
+ results []apimodel.FilterResult,
+ hidden bool,
+ err error,
+) {
+ if context == gtsmodel.FilterContextNone {
+ // fast-check any context.
+ return nil, false, nil
+ }
+
+ // Get cached filter results for status to requester in all contexts.
+ allResults, now, err := f.StatusFilterResults(ctx, requester, status)
+ if err != nil {
+ return nil, false, err
+ }
+
+ // Get results applicable to current context.
+ var forContext []cache.StatusFilterResult
+ switch context {
+ case gtsmodel.FilterContextHome:
+ forContext = allResults.Results[cache.KeyContextHome]
+ case gtsmodel.FilterContextPublic:
+ forContext = allResults.Results[cache.KeyContextPublic]
+ case gtsmodel.FilterContextNotifications:
+ forContext = allResults.Results[cache.KeyContextNotifs]
+ case gtsmodel.FilterContextThread:
+ forContext = allResults.Results[cache.KeyContextThread]
+ case gtsmodel.FilterContextAccount:
+ forContext = allResults.Results[cache.KeyContextAccount]
+ }
+
+ // Iterate results in context, gathering prepared API models.
+ results = make([]apimodel.FilterResult, 0, len(forContext))
+ for _, result := range forContext {
+
+ // Check if result expired.
+ if result.Expired(now) {
+ continue
+ }
+
+ // If the result indicates
+ // status should just be
+ // hidden then return here.
+ if result.Result == nil {
+ return nil, true, nil
+ }
+
+ // Append pre-prepared API model to slice.
+ results = append(results, *result.Result)
+ }
+
+ return
+}
+
+// StatusFilterResults returns status filtering results (in all contexts) about the given status for the given requesting account.
+func (f *Filter) StatusFilterResults(ctx context.Context, requester *gtsmodel.Account, status *gtsmodel.Status) (*cache.CachedStatusFilterResults, time.Time, error) {
+
+ // For requester ID use a
+ // fallback 'noauth' string
+ // by default for lookups.
+ requesterID := noauth
+ if requester != nil {
+ requesterID = requester.ID
+ }
+
+ // Get current time.
+ now := time.Now()
+
+ // Load status filtering results for this requesting account about status from cache, using load callback function if necessary.
+ results, err := f.state.Caches.StatusFilter.LoadOne("RequesterID,StatusID", func() (*cache.CachedStatusFilterResults, error) {
+
+ // Load status filter results for given status.
+ results, err := f.getStatusFilterResults(ctx,
+ requester,
+ status,
+ now,
+ )
+ if err != nil {
+ if err == cache.SentinelError {
+ // Filter-out our temporary
+ // race-condition error.
+ return &cache.CachedStatusFilterResults{}, nil
+ }
+
+ return nil, err
+ }
+
+ // Convert to cacheable results type.
+ return &cache.CachedStatusFilterResults{
+ StatusID: status.ID,
+ RequesterID: requesterID,
+ Results: results,
+ }, nil
+ }, requesterID, status.ID)
+ if err != nil {
+ return nil, now, err
+ }
+
+ return results, now, err
+}
+
+// getStatusFilterResults loads status filtering results for
+// the given status, given the current time (checking expiries).
+// this will load results for all possible filtering contexts.
+func (f *Filter) getStatusFilterResults(
+ ctx context.Context,
+ requester *gtsmodel.Account,
+ status *gtsmodel.Status,
+ now time.Time,
+) (
+ [5][]cache.StatusFilterResult,
+ error,
+) {
+ var results [5][]cache.StatusFilterResult
+
+ if requester == nil {
+ // Without auth, there will be no possible
+ // filters to exists, return as 'unfiltered'.
+ return results, nil
+ }
+
+ // Get the string fields status is
+ // filterable on for keyword matching.
+ fields := getFilterableFields(status)
+
+ // Get all status filters owned by the requesting account.
+ filters, err := f.state.DB.GetFiltersByAccountID(ctx, requester.ID)
+ if err != nil {
+ return results, gtserror.Newf("error getting account filters: %w", err)
+ }
+
+ // For proper status filtering we need all fields populated.
+ if err := f.state.DB.PopulateStatus(ctx, status); err != nil {
+ return results, gtserror.Newf("error populating status: %w", err)
+ }
+
+ // Generate result for each filter.
+ for _, filter := range filters {
+
+ // Skip already expired.
+ if filter.Expired(now) {
+ continue
+ }
+
+ // Later stored API result, if any.
+ // (for the HIDE action, it is unset).
+ var apiResult *apimodel.FilterResult
+
+ switch filter.Action {
+ case gtsmodel.FilterActionWarn:
+ // For filter action WARN get all possible filter matches against status.
+ keywordMatches, statusMatches := getFilterMatches(filter, status.ID, fields)
+ if len(keywordMatches) == 0 && len(statusMatches) == 0 {
+ continue
+ }
+
+ // Wrap matches in frontend API model.
+ apiResult = &apimodel.FilterResult{
+ Filter: toAPIFilterV2(filter),
+
+ KeywordMatches: keywordMatches,
+ StatusMatches: statusMatches,
+ }
+
+ // For filter action HIDE quickly
+ // look for first possible match
+ // against this status, or reloop.
+ case gtsmodel.FilterActionHide:
+ if !doesFilterMatch(filter, status.ID, fields) {
+ continue
+ }
+ }
+
+ // Wrap the filter result in our cache model.
+ // This model simply existing implies this
+ // status has been filtered, defaulting to
+ // action HIDE, or WARN on a non-nil result.
+ result := cache.StatusFilterResult{
+ Expiry: filter.ExpiresAt,
+ Result: apiResult,
+ }
+
+ // Append generated result if
+ // applies in 'home' context.
+ if filter.Contexts.Home() {
+ const key = cache.KeyContextHome
+ results[key] = append(results[key], result)
+ }
+
+ // Append generated result if
+ // applies in 'public' context.
+ if filter.Contexts.Public() {
+ const key = cache.KeyContextPublic
+ results[key] = append(results[key], result)
+ }
+
+ // Append generated result if
+ // applies in 'notifs' context.
+ if filter.Contexts.Notifications() {
+ const key = cache.KeyContextNotifs
+ results[key] = append(results[key], result)
+ }
+
+ // Append generated result if
+ // applies in 'thread' context.
+ if filter.Contexts.Thread() {
+ const key = cache.KeyContextThread
+ results[key] = append(results[key], result)
+ }
+
+ // Append generated result if
+ // applies in 'account' context.
+ if filter.Contexts.Account() {
+ const key = cache.KeyContextAccount
+ results[key] = append(results[key], result)
+ }
+ }
+
+ // Iterate all filter results.
+ for _, key := range [5]int{
+ cache.KeyContextHome,
+ cache.KeyContextPublic,
+ cache.KeyContextNotifs,
+ cache.KeyContextThread,
+ cache.KeyContextAccount,
+ } {
+ // Sort the slice of filter results by their expiry, soonest coming first.
+ slices.SortFunc(results[key], func(a, b cache.StatusFilterResult) int {
+ const k = +1
+ switch {
+ case a.Expiry.IsZero():
+ if b.Expiry.IsZero() {
+ return 0
+ }
+ return +k
+ case b.Expiry.IsZero():
+ return -k
+ case a.Expiry.Before(b.Expiry):
+ return -k
+ case b.Expiry.Before(a.Expiry):
+ return +k
+ default:
+ return 0
+ }
+ })
+ }
+
+ return results, nil
+}
+
+// getFilterMatches returns *all* the keyword and status matches of status ID and fields on given filter.
+func getFilterMatches(filter *gtsmodel.Filter, statusID string, fields []string) ([]string, []string) {
+ keywordMatches := make([]string, 0, len(filter.Keywords))
+ for _, keyword := range filter.Keywords {
+ if doesKeywordMatch(keyword.Regexp, fields) {
+ keywordMatches = append(keywordMatches, keyword.Keyword)
+ }
+ }
+ statusMatches := make([]string, 0, 1)
+ for _, status := range filter.Statuses {
+ if status.StatusID == statusID {
+ statusMatches = append(statusMatches, statusID)
+ }
+ }
+ return keywordMatches, statusMatches
+}
+
+// doesFilterMatch returns if any of fields or status ID match on the given filter.
+func doesFilterMatch(filter *gtsmodel.Filter, statusID string, fields []string) bool {
+ for _, status := range filter.Statuses {
+ if status.StatusID == statusID {
+ return true
+ }
+ }
+ for _, keyword := range filter.Keywords {
+ if doesKeywordMatch(keyword.Regexp, fields) {
+ return true
+ }
+ }
+ return false
+}
+
+// doesKeywordMatch returns if any of fields match given keyword regex.
+func doesKeywordMatch(rgx *regexp.Regexp, fields []string) bool {
+ for _, field := range fields {
+ if rgx.MatchString(field) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/filter/status/text.go b/internal/filter/status/text.go
new file mode 100644
index 000000000..347e1193c
--- /dev/null
+++ b/internal/filter/status/text.go
@@ -0,0 +1,80 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package status
+
+import (
+ "code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
+ "code.superseriousbusiness.org/gotosocial/internal/text"
+)
+
+// getFilterableFields returns text fields from
+// a status that we might want to filter on:
+//
+// - content warning
+// - content (converted to plaintext from HTML)
+// - media descriptions
+// - poll options
+//
+// Each field should be filtered separately. This avoids
+// scenarios where false-positive multiple-word matches
+// can be made by matching the last word of one field
+// combined with the first word of the next field together.
+func getFilterableFields(status *gtsmodel.Status) []string {
+
+ // Estimate expected no of status fields.
+ fieldCount := 2 + len(status.Attachments)
+ if status.Poll != nil {
+ fieldCount += len(status.Poll.Options)
+ }
+ fields := make([]string, 0, fieldCount)
+
+ // Append content warning / title.
+ if status.ContentWarning != "" {
+ fields = append(fields, status.ContentWarning)
+ }
+
+ // Status content. Though we have raw text
+ // available for statuses created on our
+ // instance, use the plaintext version to
+ // remove markdown-formatting characters
+ // and ensure more consistent filtering.
+ if status.Content != "" {
+ text := text.ParseHTMLToPlain(status.Content)
+ if text != "" {
+ fields = append(fields, text)
+ }
+ }
+
+ // Media descriptions, only where they are set.
+ for _, attachment := range status.Attachments {
+ if attachment.Description != "" {
+ fields = append(fields, attachment.Description)
+ }
+ }
+
+ // Non-empty poll options.
+ if status.Poll != nil {
+ for _, opt := range status.Poll.Options {
+ if opt != "" {
+ fields = append(fields, opt)
+ }
+ }
+ }
+
+ return fields
+}
diff --git a/internal/filter/status/text_test.go b/internal/filter/status/text_test.go
new file mode 100644
index 000000000..f9283f826
--- /dev/null
+++ b/internal/filter/status/text_test.go
@@ -0,0 +1,84 @@
+// GoToSocial
+// Copyright (C) GoToSocial Authors admin@gotosocial.org
+// SPDX-License-Identifier: AGPL-3.0-or-later
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+package status
+
+import (
+ "testing"
+
+ "code.superseriousbusiness.org/gotosocial/internal/gtsmodel"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestFilterableText(t *testing.T) {
+ type testcase struct {
+ status *gtsmodel.Status
+ expectedFields []string
+ }
+
+ for _, testcase := range []testcase{
+ {
+ status: &gtsmodel.Status{
+ ContentWarning: "This is a test status",
+ Content: `<p>Import / export of account data via CSV files will be coming in 0.17.0 :) No more having to run scripts + CLI tools to import a list of accounts you follow, after doing a migration to a <a href="https://gts.superseriousbusiness.org/tags/gotosocial" class="mention hashtag" rel="tag nofollow noreferrer noopener" target="_blank">#<span>GoToSocial</span></a> instance.</p>`,
+ },
+ expectedFields: []string{
+ "This is a test status",
+ "Import / export of account data via CSV files will be coming in 0.17.0 :) No more having to run scripts + CLI tools to import a list of accounts you follow, after doing a migration to a #GoToSocial <https://gts.superseriousbusiness.org/tags/gotosocial> instance.",
+ },
+ },
+ {
+ status: &gtsmodel.Status{
+ Content: `<p><span class="h-card"><a href="https://example.org/@zlatko" class="u-url mention" rel="nofollow noreferrer noopener" target="_blank">@<span>zlatko</span></a></span> currently we used modernc/sqlite3 for our sqlite driver, but we've been experimenting with wasm sqlite, and will likely move to that permanently in future; in the meantime, both options are available (the latter with a build tag)</p><p><a href="https://codeberg.org/superseriousbusiness/gotosocial/pulls/2863" rel="nofollow noreferrer noopener" target="_blank">https://codeberg.org/superseriousbusiness/gotosocial/pulls/2863</a></p>`,
+ },
+ expectedFields: []string{
+ "@zlatko <https://example.org/@zlatko> currently we used modernc/sqlite3 for our sqlite driver, but we've been experimenting with wasm sqlite, and will likely move to that permanently in future; in the meantime, both options are available (the latter with a build tag)\n\nhttps://codeberg.org/superseriousbusiness/gotosocial/pulls/2863 <https://codeberg.org/superseriousbusiness/gotosocial/pulls/2863>",
+ },
+ },
+ {
+ status: &gtsmodel.Status{
+ ContentWarning: "Nerd stuff",
+ Content: `<p>Latest graphs for <a href="https://gts.superseriousbusiness.org/tags/gotosocial" class="mention hashtag" rel="tag nofollow noreferrer noopener" target="_blank">#<span>GoToSocial</span></a> on <a href="https://github.com/ncruces/go-sqlite3" rel="nofollow noreferrer noopener" target="_blank">Wasm sqlite3</a> with <a href="https://codeberg.org/gruf/go-ffmpreg" rel="nofollow noreferrer noopener" target="_blank">embedded Wasm ffmpeg</a>, both running on <a href="https://wazero.io/" rel="nofollow noreferrer noopener" target="_blank">Wazero</a>, and configured with a <a href="https://codeberg.org/superseriousbusiness/gotosocial/src/commit/20fe430ef9ff3012a7a4dc2d01b68020c20e13bb/example/config.yaml#L259-L266" rel="nofollow noreferrer noopener" target="_blank">50MiB db cache target</a>. This is the version we'll be releasing soonish, now we're happy with how we've tamed everything.</p>`,
+ Attachments: []*gtsmodel.MediaAttachment{
+ {
+ Description: `Graph showing GtS using between 150-300 MiB of memory, steadily, over a few days.`,
+ },
+ {
+ Description: `Another media attachment`,
+ },
+ },
+ Poll: &gtsmodel.Poll{
+ Options: []string{
+ "Poll option 1",
+ "Poll option 2",
+ },
+ },
+ },
+ expectedFields: []string{
+ "Nerd stuff",
+ "Latest graphs for #GoToSocial <https://gts.superseriousbusiness.org/tags/gotosocial> on Wasm sqlite3 <https://github.com/ncruces/go-sqlite3> with embedded Wasm ffmpeg <https://codeberg.org/gruf/go-ffmpreg>, both running on Wazero <https://wazero.io/>, and configured with a 50MiB db cache target <https://codeberg.org/superseriousbusiness/gotosocial/src/commit/20fe430ef9ff3012a7a4dc2d01b68020c20e13bb/example/config.yaml#L259-L266>. This is the version we'll be releasing soonish, now we're happy with how we've tamed everything.",
+ "Graph showing GtS using between 150-300 MiB of memory, steadily, over a few days.",
+ "Another media attachment",
+ "Poll option 1",
+ "Poll option 2",
+ },
+ },
+ } {
+ fields := getFilterableFields(testcase.status)
+ assert.Equal(t, testcase.expectedFields, fields)
+ }
+}