mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-11-03 17:42:25 -06:00 
			
		
		
		
	
		
			
				
	
	
		
			444 lines
		
	
	
	
		
			12 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
			
		
		
	
	
			444 lines
		
	
	
	
		
			12 KiB
		
	
	
	
		
			Go
		
	
	
	
	
	
/*
 | 
						|
   GoToSocial
 | 
						|
   Copyright (C) 2021 GoToSocial Authors admin@gotosocial.org
 | 
						|
 | 
						|
   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 timeline
 | 
						|
 | 
						|
import (
 | 
						|
	"container/list"
 | 
						|
	"errors"
 | 
						|
	"fmt"
 | 
						|
	"sync"
 | 
						|
	"time"
 | 
						|
 | 
						|
	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model"
 | 
						|
	"github.com/superseriousbusiness/gotosocial/internal/db"
 | 
						|
	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
 | 
						|
	"github.com/superseriousbusiness/gotosocial/internal/typeutils"
 | 
						|
)
 | 
						|
 | 
						|
const (
 | 
						|
	preparedPostsMaxLength = desiredPostIndexLength
 | 
						|
)
 | 
						|
 | 
						|
// Timeline represents a timeline for one account, and contains indexed and prepared posts.
 | 
						|
type Timeline interface {
 | 
						|
	/*
 | 
						|
		RETRIEVAL FUNCTIONS
 | 
						|
	*/
 | 
						|
 | 
						|
	// GetXFromTop returns x amount of posts from the top of the timeline, from newest to oldest.
 | 
						|
	GetXFromTop(amount int) ([]*apimodel.Status, error)
 | 
						|
	// GetXFromIDOnwards returns x amount of posts from the given id onwards, from newest to oldest.
 | 
						|
	// This will include the status with the given ID.
 | 
						|
	GetXFromIDOnwards(amount int, fromID string) ([]*apimodel.Status, error)
 | 
						|
	// GetXBeforeID returns x amount of posts up to the given id, from newest to oldest.
 | 
						|
	// This will NOT include the status with the given ID.
 | 
						|
	GetXBeforeID(amount int, sinceID string) ([]*apimodel.Status, error)
 | 
						|
 | 
						|
	/*
 | 
						|
		INDEXING FUNCTIONS
 | 
						|
	*/
 | 
						|
 | 
						|
	// IndexOne puts a status into the timeline at the appropriate place according to its 'createdAt' property.
 | 
						|
	IndexOne(statusCreatedAt time.Time, statusID string) error
 | 
						|
	// Remove removes a status from the timeline.
 | 
						|
	Remove(statusID string) error
 | 
						|
	// OldestIndexedPostID returns the id of the rearmost (ie., the oldest) indexed post, or an error if something goes wrong.
 | 
						|
	// If nothing goes wrong but there's no oldest post, an empty string will be returned so make sure to check for this.
 | 
						|
	OldestIndexedPostID() (string, error)
 | 
						|
 | 
						|
	/*
 | 
						|
		PREPARATION FUNCTIONS
 | 
						|
	*/
 | 
						|
 | 
						|
	// PrepareXFromTop instructs the timeline to prepare x amount of posts from the top of the timeline.
 | 
						|
	PrepareXFromTop(amount int) error
 | 
						|
	// PrepareXFromIndex instrucst the timeline to prepare the next amount of entries for serialization, from index onwards.
 | 
						|
	PrepareXFromIndex(amount int, index int) error
 | 
						|
	// IndexOne puts a status into the timeline at the appropriate place according to its 'createdAt' property,
 | 
						|
	// and then immediately prepares it.
 | 
						|
	IndexAndPrepareOne(statusCreatedAt time.Time, statusID string) error
 | 
						|
 | 
						|
	/*
 | 
						|
		INFO FUNCTIONS
 | 
						|
	*/
 | 
						|
 | 
						|
	// ActualPostIndexLength returns the actual length of the post index at this point in time.
 | 
						|
	PostIndexLength() int
 | 
						|
 | 
						|
	/*
 | 
						|
		UTILITY FUNCTIONS
 | 
						|
	*/
 | 
						|
 | 
						|
	// Reset instructs the timeline to reset to its base state -- cache only the minimum amount of posts.
 | 
						|
	Reset() error
 | 
						|
}
 | 
						|
 | 
						|
// timeline fulfils the Timeline interface
 | 
						|
type timeline struct {
 | 
						|
	postIndex     *postIndex
 | 
						|
	preparedPosts *preparedPosts
 | 
						|
	accountID     string
 | 
						|
	account       *gtsmodel.Account
 | 
						|
	db            db.DB
 | 
						|
	tc            typeutils.TypeConverter
 | 
						|
	sync.Mutex
 | 
						|
}
 | 
						|
 | 
						|
// NewTimeline returns a new Timeline for the given account ID
 | 
						|
func NewTimeline(accountID string, db db.DB, typeConverter typeutils.TypeConverter) Timeline {
 | 
						|
	return &timeline{
 | 
						|
		postIndex:     &postIndex{},
 | 
						|
		preparedPosts: &preparedPosts{},
 | 
						|
		accountID:     accountID,
 | 
						|
		db:            db,
 | 
						|
		tc:            typeConverter,
 | 
						|
	}
 | 
						|
}
 | 
						|
 | 
						|
func (t *timeline) PrepareXFromIndex(amount int, index int) error {
 | 
						|
	t.Lock()
 | 
						|
	defer t.Unlock()
 | 
						|
 | 
						|
	var indexed int
 | 
						|
	var prepared int
 | 
						|
	var preparing bool
 | 
						|
	for e := t.postIndex.data.Front(); e != nil; e = e.Next() {
 | 
						|
		entry, ok := e.Value.(*postIndexEntry)
 | 
						|
		if !ok {
 | 
						|
			return errors.New("PrepareXFromTop: could not parse e as a postIndexEntry")
 | 
						|
		}
 | 
						|
 | 
						|
		if !preparing {
 | 
						|
			// we haven't hit the index we need to prepare from yet
 | 
						|
			if indexed == index {
 | 
						|
				preparing = true
 | 
						|
			}
 | 
						|
			indexed = indexed + 1
 | 
						|
			continue
 | 
						|
		} else {
 | 
						|
			if err := t.prepare(entry.statusID); err != nil {
 | 
						|
				return fmt.Errorf("PrepareXFromTop: error preparing status with id %s: %s", entry.statusID, err)
 | 
						|
			}
 | 
						|
			prepared = prepared + 1
 | 
						|
			if prepared >= amount {
 | 
						|
				// we're done
 | 
						|
				break
 | 
						|
			}
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	return nil
 | 
						|
}
 | 
						|
 | 
						|
func (t *timeline) PrepareXFromTop(amount int) error {
 | 
						|
	t.Lock()
 | 
						|
	defer t.Unlock()
 | 
						|
 | 
						|
	t.preparedPosts.data.Init()
 | 
						|
 | 
						|
	var prepared int
 | 
						|
	for e := t.postIndex.data.Front(); e != nil; e = e.Next() {
 | 
						|
		entry, ok := e.Value.(*postIndexEntry)
 | 
						|
		if !ok {
 | 
						|
			return errors.New("PrepareXFromTop: could not parse e as a postIndexEntry")
 | 
						|
		}
 | 
						|
 | 
						|
		if err := t.prepare(entry.statusID); err != nil {
 | 
						|
			return fmt.Errorf("PrepareXFromTop: error preparing status with id %s: %s", entry.statusID, err)
 | 
						|
		}
 | 
						|
 | 
						|
		prepared = prepared + 1
 | 
						|
		if prepared >= amount {
 | 
						|
			// we're done
 | 
						|
			break
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	return nil
 | 
						|
}
 | 
						|
 | 
						|
func (t *timeline) GetXFromTop(amount int) ([]*apimodel.Status, error) {
 | 
						|
	// make a slice of statuses with the length we need to return
 | 
						|
	statuses := make([]*apimodel.Status, 0, amount)
 | 
						|
 | 
						|
	// if there are no prepared posts, just return the empty slice
 | 
						|
	if t.preparedPosts.data == nil {
 | 
						|
		t.preparedPosts.data = &list.List{}
 | 
						|
	}
 | 
						|
 | 
						|
	// make sure we have enough posts prepared to return
 | 
						|
	if t.preparedPosts.data.Len() < amount {
 | 
						|
		if err := t.PrepareXFromTop(amount); err != nil {
 | 
						|
			return nil, err
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	// work through the prepared posts from the top and return
 | 
						|
	var served int
 | 
						|
	for e := t.preparedPosts.data.Front(); e != nil; e = e.Next() {
 | 
						|
		entry, ok := e.Value.(*preparedPostsEntry)
 | 
						|
		if !ok {
 | 
						|
			return nil, errors.New("GetXFromTop: could not parse e as a preparedPostsEntry")
 | 
						|
		}
 | 
						|
		statuses = append(statuses, entry.prepared)
 | 
						|
		served = served + 1
 | 
						|
		if served >= amount {
 | 
						|
			break
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	return statuses, nil
 | 
						|
}
 | 
						|
 | 
						|
func (t *timeline) GetXFromIDOnwards(amount int, fromID string) ([]*apimodel.Status, error) {
 | 
						|
	// make a slice of statuses with the length we need to return
 | 
						|
	statuses := make([]*apimodel.Status, 0, amount)
 | 
						|
 | 
						|
	// if there are no prepared posts, just return the empty slice
 | 
						|
	if t.preparedPosts.data == nil {
 | 
						|
		t.preparedPosts.data = &list.List{}
 | 
						|
	}
 | 
						|
 | 
						|
	// find the position of id
 | 
						|
	var position int
 | 
						|
	for e := t.preparedPosts.data.Front(); e != nil; e = e.Next() {
 | 
						|
		entry, ok := e.Value.(*preparedPostsEntry)
 | 
						|
		if !ok {
 | 
						|
			return nil, errors.New("GetXBehindID: could not parse e as a preparedPostsEntry")
 | 
						|
		}
 | 
						|
		if entry.statusID == fromID {
 | 
						|
			break
 | 
						|
		}
 | 
						|
		position = position + 1
 | 
						|
	}
 | 
						|
 | 
						|
	// make sure we have enough posts prepared behind it to return what we're being asked for
 | 
						|
	if t.preparedPosts.data.Len() < amount+position {
 | 
						|
		if err := t.PrepareXFromIndex(amount, position); err != nil {
 | 
						|
			return nil, err
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	// iterate through the modified list until we hit the fromID again
 | 
						|
	var serving bool
 | 
						|
	var served int
 | 
						|
	for e := t.preparedPosts.data.Front(); e != nil; e = e.Next() {
 | 
						|
		entry, ok := e.Value.(*preparedPostsEntry)
 | 
						|
		if !ok {
 | 
						|
			return nil, errors.New("GetXBehindID: could not parse e as a preparedPostsEntry")
 | 
						|
		}
 | 
						|
 | 
						|
		if !serving {
 | 
						|
			// start serving if we've hit the id we're looking for
 | 
						|
			if entry.statusID == fromID {
 | 
						|
				serving = true
 | 
						|
			}
 | 
						|
		}
 | 
						|
 | 
						|
		if serving {
 | 
						|
			// serve up to the amount requested
 | 
						|
			statuses = append(statuses, entry.prepared)
 | 
						|
			served = served + 1
 | 
						|
			if served >= amount {
 | 
						|
				break
 | 
						|
			}
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	return statuses, nil
 | 
						|
}
 | 
						|
 | 
						|
func (t *timeline) GetXBeforeID(amount int, beforeID string) ([]*apimodel.Status, error) {
 | 
						|
	// make a slice of statuses with the length we need to return
 | 
						|
	statuses := make([]*apimodel.Status, 0, amount)
 | 
						|
 | 
						|
	// if there are no prepared posts, just return the empty slice
 | 
						|
	if t.preparedPosts.data == nil {
 | 
						|
		t.preparedPosts.data = &list.List{}
 | 
						|
	}
 | 
						|
 | 
						|
	// iterate through the modified list until we hit the fromID again
 | 
						|
	var served int
 | 
						|
servloop:
 | 
						|
	for e := t.preparedPosts.data.Front(); e != nil; e = e.Next() {
 | 
						|
		entry, ok := e.Value.(*preparedPostsEntry)
 | 
						|
		if !ok {
 | 
						|
			return nil, errors.New("GetXBeforeID: could not parse e as a preparedPostsEntry")
 | 
						|
		}
 | 
						|
 | 
						|
		if entry.statusID == beforeID {
 | 
						|
			// we're good
 | 
						|
			break servloop
 | 
						|
		}
 | 
						|
 | 
						|
		// serve up to the amount requested
 | 
						|
		statuses = append(statuses, entry.prepared)
 | 
						|
		served = served + 1
 | 
						|
		if served >= amount {
 | 
						|
			break
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	return statuses, nil
 | 
						|
}
 | 
						|
 | 
						|
func (t *timeline) IndexOne(statusCreatedAt time.Time, statusID string) error {
 | 
						|
	t.Lock()
 | 
						|
	defer t.Unlock()
 | 
						|
 | 
						|
	postIndexEntry := &postIndexEntry{
 | 
						|
		createdAt: statusCreatedAt,
 | 
						|
		statusID:  statusID,
 | 
						|
	}
 | 
						|
 | 
						|
	return t.postIndex.insertIndexed(postIndexEntry)
 | 
						|
}
 | 
						|
 | 
						|
func (t *timeline) IndexAndPrepareOne(statusCreatedAt time.Time, statusID string) error {
 | 
						|
	t.Lock()
 | 
						|
	defer t.Unlock()
 | 
						|
 | 
						|
	postIndexEntry := &postIndexEntry{
 | 
						|
		createdAt: statusCreatedAt,
 | 
						|
		statusID:  statusID,
 | 
						|
	}
 | 
						|
 | 
						|
	if err := t.postIndex.insertIndexed(postIndexEntry); err != nil {
 | 
						|
		return fmt.Errorf("IndexAndPrepareOne: error inserting indexed: %s", err)
 | 
						|
	}
 | 
						|
 | 
						|
	if err := t.prepare(statusID); err != nil {
 | 
						|
		return fmt.Errorf("IndexAndPrepareOne: error preparing: %s", err)
 | 
						|
	}
 | 
						|
 | 
						|
	return nil
 | 
						|
}
 | 
						|
 | 
						|
func (t *timeline) Remove(statusID string) error {
 | 
						|
	t.Lock()
 | 
						|
	defer t.Unlock()
 | 
						|
 | 
						|
	// remove the entry from the post index
 | 
						|
	for e := t.postIndex.data.Front(); e != nil; e = e.Next() {
 | 
						|
		entry, ok := e.Value.(*postIndexEntry)
 | 
						|
		if !ok {
 | 
						|
			return errors.New("Remove: could not parse e as a postIndexEntry")
 | 
						|
		}
 | 
						|
		if entry.statusID == statusID {
 | 
						|
			t.postIndex.data.Remove(e)
 | 
						|
			break // bail once we found and removed it
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	// remove the entry from prepared posts
 | 
						|
	for e := t.preparedPosts.data.Front(); e != nil; e = e.Next() {
 | 
						|
		entry, ok := e.Value.(*preparedPostsEntry)
 | 
						|
		if !ok {
 | 
						|
			return errors.New("Remove: could not parse e as a preparedPostsEntry")
 | 
						|
		}
 | 
						|
		if entry.statusID == statusID {
 | 
						|
			t.preparedPosts.data.Remove(e)
 | 
						|
			break // bail once we found and removed it
 | 
						|
		}
 | 
						|
	}
 | 
						|
 | 
						|
	return nil
 | 
						|
}
 | 
						|
 | 
						|
func (t *timeline) Reset() error {
 | 
						|
	return nil
 | 
						|
}
 | 
						|
 | 
						|
func (t *timeline) PostIndexLength() int {
 | 
						|
	if t.postIndex == nil || t.postIndex.data == nil {
 | 
						|
		return 0
 | 
						|
	}
 | 
						|
 | 
						|
	return t.postIndex.data.Len()
 | 
						|
}
 | 
						|
 | 
						|
func (t *timeline) OldestIndexedPostID() (string, error) {
 | 
						|
	var id string
 | 
						|
	if t.postIndex == nil || t.postIndex.data == nil {
 | 
						|
		// return an empty string if postindex hasn't been initialized yet
 | 
						|
		return id, nil
 | 
						|
	}
 | 
						|
 | 
						|
	e := t.postIndex.data.Back()
 | 
						|
 | 
						|
	if e == nil {
 | 
						|
		// return an empty string if there's no back entry (ie., the index list hasn't been initialized yet)
 | 
						|
		return id, nil
 | 
						|
	}
 | 
						|
 | 
						|
	entry, ok := e.Value.(*postIndexEntry)
 | 
						|
	if !ok {
 | 
						|
		return id, errors.New("OldestIndexedPostID: could not parse e as a postIndexEntry")
 | 
						|
	}
 | 
						|
	return entry.statusID, nil
 | 
						|
}
 | 
						|
 | 
						|
func (t *timeline) prepare(statusID string) error {
 | 
						|
 | 
						|
	// start by getting the status out of the database according to its indexed ID
 | 
						|
	gtsStatus := >smodel.Status{}
 | 
						|
	if err := t.db.GetByID(statusID, gtsStatus); err != nil {
 | 
						|
		return err
 | 
						|
	}
 | 
						|
 | 
						|
	// if the account pointer hasn't been set on this timeline already, set it lazily here
 | 
						|
	if t.account == nil {
 | 
						|
		timelineOwnerAccount := >smodel.Account{}
 | 
						|
		if err := t.db.GetByID(t.accountID, timelineOwnerAccount); err != nil {
 | 
						|
			return err
 | 
						|
		}
 | 
						|
		t.account = timelineOwnerAccount
 | 
						|
	}
 | 
						|
 | 
						|
	// to convert the status we need relevant accounts from it, so pull them out here
 | 
						|
	relevantAccounts, err := t.db.PullRelevantAccountsFromStatus(gtsStatus)
 | 
						|
	if err != nil {
 | 
						|
		return err
 | 
						|
	}
 | 
						|
 | 
						|
	// check if this is a boost...
 | 
						|
	var reblogOfStatus *gtsmodel.Status
 | 
						|
	if gtsStatus.BoostOfID != "" {
 | 
						|
		s := >smodel.Status{}
 | 
						|
		if err := t.db.GetByID(gtsStatus.BoostOfID, s); err != nil {
 | 
						|
			return err
 | 
						|
		}
 | 
						|
		reblogOfStatus = s
 | 
						|
	}
 | 
						|
 | 
						|
	// serialize the status (or, at least, convert it to a form that's ready to be serialized)
 | 
						|
	apiModelStatus, err := t.tc.StatusToMasto(gtsStatus, relevantAccounts.StatusAuthor, t.account, relevantAccounts.BoostedAccount, relevantAccounts.ReplyToAccount, reblogOfStatus)
 | 
						|
	if err != nil {
 | 
						|
		return err
 | 
						|
	}
 | 
						|
 | 
						|
	// shove it in prepared posts as a prepared posts entry
 | 
						|
	preparedPostsEntry := &preparedPostsEntry{
 | 
						|
		createdAt: gtsStatus.CreatedAt,
 | 
						|
		statusID:  statusID,
 | 
						|
		prepared:  apiModelStatus,
 | 
						|
	}
 | 
						|
 | 
						|
	return t.preparedPosts.insertPrepared(preparedPostsEntry)
 | 
						|
}
 |