mirror of
				https://github.com/superseriousbusiness/gotosocial.git
				synced 2025-10-31 01:12:24 -05:00 
			
		
		
		
	[feature] Push status edit messages into open streams (#2418)
* push status edit messages into open streams * fix a few comments * test++ * commented out code? moi?
This commit is contained in:
		
					parent
					
						
							
								fbe4e60232
							
						
					
				
			
			
				commit
				
					
						285d55dda8
					
				
			
		
					 7 changed files with 363 additions and 0 deletions
				
			
		
							
								
								
									
										38
									
								
								internal/processing/stream/statusupdate.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								internal/processing/stream/statusupdate.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,38 @@ | ||||||
|  | // 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 stream | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"fmt" | ||||||
|  | 
 | ||||||
|  | 	apimodel "github.com/superseriousbusiness/gotosocial/internal/api/model" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/gtsmodel" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/stream" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | // StatusUpdate streams the given edited status to any open, appropriate | ||||||
|  | // streams belonging to the given account. | ||||||
|  | func (p *Processor) StatusUpdate(s *apimodel.Status, account *gtsmodel.Account, streamTypes []string) error { | ||||||
|  | 	bytes, err := json.Marshal(s) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return fmt.Errorf("error marshalling status to json: %s", err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return p.toAccount(string(bytes), stream.EventTypeStatusUpdate, streamTypes, account.ID) | ||||||
|  | } | ||||||
							
								
								
									
										137
									
								
								internal/processing/stream/statusupdate_test.go
									
										
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								internal/processing/stream/statusupdate_test.go
									
										
									
									
									
										Normal file
									
								
							|  | @ -0,0 +1,137 @@ | ||||||
|  | // 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 stream_test | ||||||
|  | 
 | ||||||
|  | import ( | ||||||
|  | 	"bytes" | ||||||
|  | 	"context" | ||||||
|  | 	"encoding/json" | ||||||
|  | 	"testing" | ||||||
|  | 
 | ||||||
|  | 	"github.com/stretchr/testify/suite" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/stream" | ||||||
|  | 	"github.com/superseriousbusiness/gotosocial/internal/typeutils" | ||||||
|  | ) | ||||||
|  | 
 | ||||||
|  | type StatusUpdateTestSuite struct { | ||||||
|  | 	StreamTestSuite | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func (suite *StatusUpdateTestSuite) TestStreamNotification() { | ||||||
|  | 	account := suite.testAccounts["local_account_1"] | ||||||
|  | 
 | ||||||
|  | 	openStream, errWithCode := suite.streamProcessor.Open(context.Background(), account, "user") | ||||||
|  | 	suite.NoError(errWithCode) | ||||||
|  | 
 | ||||||
|  | 	editedStatus := suite.testStatuses["remote_account_1_status_1"] | ||||||
|  | 	apiStatus, err := typeutils.NewConverter(&suite.state).StatusToAPIStatus(context.Background(), editedStatus, account) | ||||||
|  | 	suite.NoError(err) | ||||||
|  | 
 | ||||||
|  | 	err = suite.streamProcessor.StatusUpdate(apiStatus, account, []string{stream.TimelineHome}) | ||||||
|  | 	suite.NoError(err) | ||||||
|  | 
 | ||||||
|  | 	msg := <-openStream.Messages | ||||||
|  | 	dst := new(bytes.Buffer) | ||||||
|  | 	err = json.Indent(dst, []byte(msg.Payload), "", "  ") | ||||||
|  | 	suite.NoError(err) | ||||||
|  | 	suite.Equal(`{ | ||||||
|  |   "id": "01FVW7JHQFSFK166WWKR8CBA6M", | ||||||
|  |   "created_at": "2021-09-20T10:40:37.000Z", | ||||||
|  |   "in_reply_to_id": null, | ||||||
|  |   "in_reply_to_account_id": null, | ||||||
|  |   "sensitive": false, | ||||||
|  |   "spoiler_text": "", | ||||||
|  |   "visibility": "unlisted", | ||||||
|  |   "language": "en", | ||||||
|  |   "uri": "http://fossbros-anonymous.io/users/foss_satan/statuses/01FVW7JHQFSFK166WWKR8CBA6M", | ||||||
|  |   "url": "http://fossbros-anonymous.io/@foss_satan/statuses/01FVW7JHQFSFK166WWKR8CBA6M", | ||||||
|  |   "replies_count": 0, | ||||||
|  |   "reblogs_count": 0, | ||||||
|  |   "favourites_count": 0, | ||||||
|  |   "favourited": false, | ||||||
|  |   "reblogged": false, | ||||||
|  |   "muted": false, | ||||||
|  |   "bookmarked": false, | ||||||
|  |   "pinned": false, | ||||||
|  |   "content": "dark souls status bot: \"thoughts of dog\"", | ||||||
|  |   "reblog": null, | ||||||
|  |   "account": { | ||||||
|  |     "id": "01F8MH5ZK5VRH73AKHQM6Y9VNX", | ||||||
|  |     "username": "foss_satan", | ||||||
|  |     "acct": "foss_satan@fossbros-anonymous.io", | ||||||
|  |     "display_name": "big gerald", | ||||||
|  |     "locked": false, | ||||||
|  |     "discoverable": true, | ||||||
|  |     "bot": false, | ||||||
|  |     "created_at": "2021-09-26T10:52:36.000Z", | ||||||
|  |     "note": "i post about like, i dunno, stuff, or whatever!!!!", | ||||||
|  |     "url": "http://fossbros-anonymous.io/@foss_satan", | ||||||
|  |     "avatar": "", | ||||||
|  |     "avatar_static": "", | ||||||
|  |     "header": "http://localhost:8080/assets/default_header.png", | ||||||
|  |     "header_static": "http://localhost:8080/assets/default_header.png", | ||||||
|  |     "followers_count": 0, | ||||||
|  |     "following_count": 0, | ||||||
|  |     "statuses_count": 3, | ||||||
|  |     "last_status_at": "2021-09-11T09:40:37.000Z", | ||||||
|  |     "emojis": [], | ||||||
|  |     "fields": [] | ||||||
|  |   }, | ||||||
|  |   "media_attachments": [ | ||||||
|  |     { | ||||||
|  |       "id": "01FVW7RXPQ8YJHTEXYPE7Q8ZY0", | ||||||
|  |       "type": "image", | ||||||
|  |       "url": "http://localhost:8080/fileserver/01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/original/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpg", | ||||||
|  |       "text_url": "http://localhost:8080/fileserver/01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/original/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpg", | ||||||
|  |       "preview_url": "http://localhost:8080/fileserver/01F8MH5ZK5VRH73AKHQM6Y9VNX/attachment/small/01FVW7RXPQ8YJHTEXYPE7Q8ZY0.jpg", | ||||||
|  |       "remote_url": "http://fossbros-anonymous.io/attachments/original/13bbc3f8-2b5e-46ea-9531-40b4974d9912.jpg", | ||||||
|  |       "preview_remote_url": "http://fossbros-anonymous.io/attachments/small/a499f55b-2d1e-4acd-98d2-1ac2ba6d79b9.jpg", | ||||||
|  |       "meta": { | ||||||
|  |         "original": { | ||||||
|  |           "width": 472, | ||||||
|  |           "height": 291, | ||||||
|  |           "size": "472x291", | ||||||
|  |           "aspect": 1.6219932 | ||||||
|  |         }, | ||||||
|  |         "small": { | ||||||
|  |           "width": 472, | ||||||
|  |           "height": 291, | ||||||
|  |           "size": "472x291", | ||||||
|  |           "aspect": 1.6219932 | ||||||
|  |         }, | ||||||
|  |         "focus": { | ||||||
|  |           "x": 0, | ||||||
|  |           "y": 0 | ||||||
|  |         } | ||||||
|  |       }, | ||||||
|  |       "description": "tweet from thoughts of dog: i drank. all the water. in my bowl. earlier. but just now. i returned. to the same bowl. and it was. full again.. the bowl. is haunted", | ||||||
|  |       "blurhash": "LARysgM_IU_3~pD%M_Rj_39FIAt6" | ||||||
|  |     } | ||||||
|  |   ], | ||||||
|  |   "mentions": [], | ||||||
|  |   "tags": [], | ||||||
|  |   "emojis": [], | ||||||
|  |   "card": null, | ||||||
|  |   "poll": null | ||||||
|  | }`, dst.String()) | ||||||
|  | 	suite.Equal(msg.Event, "status.update") | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | func TestStatusUpdateTestSuite(t *testing.T) { | ||||||
|  | 	suite.Run(t, &StatusUpdateTestSuite{}) | ||||||
|  | } | ||||||
|  | @ -30,6 +30,7 @@ import ( | ||||||
| type StreamTestSuite struct { | type StreamTestSuite struct { | ||||||
| 	suite.Suite | 	suite.Suite | ||||||
| 	testAccounts map[string]*gtsmodel.Account | 	testAccounts map[string]*gtsmodel.Account | ||||||
|  | 	testStatuses map[string]*gtsmodel.Status | ||||||
| 	testTokens   map[string]*gtsmodel.Token | 	testTokens   map[string]*gtsmodel.Token | ||||||
| 	db           db.DB | 	db           db.DB | ||||||
| 	oauthServer  oauth.Server | 	oauthServer  oauth.Server | ||||||
|  | @ -45,6 +46,7 @@ func (suite *StreamTestSuite) SetupTest() { | ||||||
| 	testrig.InitTestConfig() | 	testrig.InitTestConfig() | ||||||
| 
 | 
 | ||||||
| 	suite.testAccounts = testrig.NewTestAccounts() | 	suite.testAccounts = testrig.NewTestAccounts() | ||||||
|  | 	suite.testStatuses = testrig.NewTestStatuses() | ||||||
| 	suite.testTokens = testrig.NewTestTokens() | 	suite.testTokens = testrig.NewTestTokens() | ||||||
| 	suite.db = testrig.NewTestDB(&suite.state) | 	suite.db = testrig.NewTestDB(&suite.state) | ||||||
| 	suite.state.DB = suite.db | 	suite.state.DB = suite.db | ||||||
|  |  | ||||||
|  | @ -416,6 +416,11 @@ func (p *clientAPI) UpdateStatus(ctx context.Context, cMsg messages.FromClientAP | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	// Push message that the status has been edited to streams. | ||||||
|  | 	if err := p.surface.timelineStatusUpdate(ctx, status); err != nil { | ||||||
|  | 		log.Errorf(ctx, "error streaming status edit: %v", err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -530,6 +530,11 @@ func (p *fediAPI) UpdateStatus(ctx context.Context, fMsg messages.FromFediAPI) e | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 
 | 
 | ||||||
|  | 	// Push message that the status has been edited to streams. | ||||||
|  | 	if err := p.surface.timelineStatusUpdate(ctx, status); err != nil { | ||||||
|  | 		log.Errorf(ctx, "error streaming status edit: %v", err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
| 	return nil | 	return nil | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -390,3 +390,176 @@ func (s *surface) invalidateStatusFromTimelines(ctx context.Context, statusID st | ||||||
| 			Errorf("error unpreparing status from list timelines: %v", err) | 			Errorf("error unpreparing status from list timelines: %v", err) | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  | 
 | ||||||
|  | // timelineStatusUpdate looks up HOME and LIST timelines of accounts | ||||||
|  | // that follow the the status author and pushes edit messages into any | ||||||
|  | // active streams. | ||||||
|  | // Note that calling invalidateStatusFromTimelines takes care of the | ||||||
|  | // state in general, we just need to do this for any streams that are | ||||||
|  | // open right now. | ||||||
|  | func (s *surface) timelineStatusUpdate(ctx context.Context, status *gtsmodel.Status) error { | ||||||
|  | 	// Ensure status fully populated; including account, mentions, etc. | ||||||
|  | 	if err := s.state.DB.PopulateStatus(ctx, status); err != nil { | ||||||
|  | 		return gtserror.Newf("error populating status with id %s: %w", status.ID, err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Get all local followers of the account that posted the status. | ||||||
|  | 	follows, err := s.state.DB.GetAccountLocalFollowers(ctx, status.AccountID) | ||||||
|  | 	if err != nil { | ||||||
|  | 		return gtserror.Newf("error getting local followers of account %s: %w", status.AccountID, err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// If the poster is also local, add a fake entry for them | ||||||
|  | 	// so they can see their own status in their timeline. | ||||||
|  | 	if status.Account.IsLocal() { | ||||||
|  | 		follows = append(follows, >smodel.Follow{ | ||||||
|  | 			AccountID:   status.AccountID, | ||||||
|  | 			Account:     status.Account, | ||||||
|  | 			Notify:      func() *bool { b := false; return &b }(), // Account shouldn't notify itself. | ||||||
|  | 			ShowReblogs: func() *bool { b := true; return &b }(),  // Account should show own reblogs. | ||||||
|  | 		}) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Push to streams for each local follower of this account. | ||||||
|  | 	if err := s.timelineStatusUpdateForFollowers(ctx, status, follows); err != nil { | ||||||
|  | 		return gtserror.Newf("error timelining status %s for followers: %w", status.ID, err) | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // timelineStatusUpdateForFollowers iterates through the given | ||||||
|  | // slice of followers of the account that posted the given status, | ||||||
|  | // pushing update messages into open list/home streams of each | ||||||
|  | // follower. | ||||||
|  | func (s *surface) timelineStatusUpdateForFollowers( | ||||||
|  | 	ctx context.Context, | ||||||
|  | 	status *gtsmodel.Status, | ||||||
|  | 	follows []*gtsmodel.Follow, | ||||||
|  | ) error { | ||||||
|  | 	var ( | ||||||
|  | 		errs gtserror.MultiError | ||||||
|  | 	) | ||||||
|  | 
 | ||||||
|  | 	for _, follow := range follows { | ||||||
|  | 		// Check to see if the status is timelineable for this follower, | ||||||
|  | 		// taking account of its visibility, who it replies to, and, if | ||||||
|  | 		// it's a reblog, whether follower account wants to see reblogs. | ||||||
|  | 		// | ||||||
|  | 		// If it's not timelineable, we can just stop early, since lists | ||||||
|  | 		// are prettymuch subsets of the home timeline, so if it shouldn't | ||||||
|  | 		// appear there, it shouldn't appear in lists either. | ||||||
|  | 		timelineable, err := s.filter.StatusHomeTimelineable( | ||||||
|  | 			ctx, follow.Account, status, | ||||||
|  | 		) | ||||||
|  | 		if err != nil { | ||||||
|  | 			errs.Appendf("error checking status %s hometimelineability: %w", status.ID, err) | ||||||
|  | 			continue | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		if !timelineable { | ||||||
|  | 			// Nothing to do. | ||||||
|  | 			continue | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		// Add status to any relevant lists | ||||||
|  | 		// for this follow, if applicable. | ||||||
|  | 		s.listTimelineStatusUpdateForFollow( | ||||||
|  | 			ctx, | ||||||
|  | 			status, | ||||||
|  | 			follow, | ||||||
|  | 			&errs, | ||||||
|  | 		) | ||||||
|  | 
 | ||||||
|  | 		// Add status to home timeline for owner | ||||||
|  | 		// of this follow, if applicable. | ||||||
|  | 		err = s.timelineStreamStatusUpdate( | ||||||
|  | 			ctx, | ||||||
|  | 			follow.Account, | ||||||
|  | 			status, | ||||||
|  | 			stream.TimelineHome, | ||||||
|  | 		) | ||||||
|  | 		if err != nil { | ||||||
|  | 			errs.Appendf("error home timelining status: %w", err) | ||||||
|  | 			continue | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return errs.Combine() | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // listTimelineStatusUpdateForFollow pushes edits of the given status | ||||||
|  | // into any eligible lists streams opened by the given follower. | ||||||
|  | func (s *surface) listTimelineStatusUpdateForFollow( | ||||||
|  | 	ctx context.Context, | ||||||
|  | 	status *gtsmodel.Status, | ||||||
|  | 	follow *gtsmodel.Follow, | ||||||
|  | 	errs *gtserror.MultiError, | ||||||
|  | ) { | ||||||
|  | 	// To put this status in appropriate list timelines, | ||||||
|  | 	// we need to get each listEntry that pertains to | ||||||
|  | 	// this follow. Then, we want to iterate through all | ||||||
|  | 	// those list entries, and add the status to the list | ||||||
|  | 	// that the entry belongs to if it meets criteria for | ||||||
|  | 	// inclusion in the list. | ||||||
|  | 
 | ||||||
|  | 	// Get every list entry that targets this follow's ID. | ||||||
|  | 	listEntries, err := s.state.DB.GetListEntriesForFollowID( | ||||||
|  | 		// We only need the list IDs. | ||||||
|  | 		gtscontext.SetBarebones(ctx), | ||||||
|  | 		follow.ID, | ||||||
|  | 	) | ||||||
|  | 	if err != nil && !errors.Is(err, db.ErrNoEntries) { | ||||||
|  | 		errs.Appendf("error getting list entries: %w", err) | ||||||
|  | 		return | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	// Check eligibility for each list entry (if any). | ||||||
|  | 	for _, listEntry := range listEntries { | ||||||
|  | 		eligible, err := s.listEligible(ctx, listEntry, status) | ||||||
|  | 		if err != nil { | ||||||
|  | 			errs.Appendf("error checking list eligibility: %w", err) | ||||||
|  | 			continue | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		if !eligible { | ||||||
|  | 			// Don't add this. | ||||||
|  | 			continue | ||||||
|  | 		} | ||||||
|  | 
 | ||||||
|  | 		// At this point we are certain this status | ||||||
|  | 		// should be included in the timeline of the | ||||||
|  | 		// list that this list entry belongs to. | ||||||
|  | 		if err := s.timelineStreamStatusUpdate( | ||||||
|  | 			ctx, | ||||||
|  | 			follow.Account, | ||||||
|  | 			status, | ||||||
|  | 			stream.TimelineList+":"+listEntry.ListID, // key streamType to this specific list | ||||||
|  | 		); err != nil { | ||||||
|  | 			errs.Appendf("error adding status to timeline for list %s: %w", listEntry.ListID, err) | ||||||
|  | 			// implicit continue | ||||||
|  | 		} | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | // timelineStatusUpdate streams the edited status to the user using the | ||||||
|  | // given streamType. | ||||||
|  | func (s *surface) timelineStreamStatusUpdate( | ||||||
|  | 	ctx context.Context, | ||||||
|  | 	account *gtsmodel.Account, | ||||||
|  | 	status *gtsmodel.Status, | ||||||
|  | 	streamType string, | ||||||
|  | ) error { | ||||||
|  | 	apiStatus, err := s.converter.StatusToAPIStatus(ctx, status, account) | ||||||
|  | 	if err != nil { | ||||||
|  | 		err = gtserror.Newf("error converting status %s to frontend representation: %w", status.ID, err) | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	if err := s.stream.StatusUpdate(apiStatus, account, []string{streamType}); err != nil { | ||||||
|  | 		err = gtserror.Newf("error streaming update for status %s: %w", status.ID, err) | ||||||
|  | 		return err | ||||||
|  | 	} | ||||||
|  | 
 | ||||||
|  | 	return nil | ||||||
|  | } | ||||||
|  |  | ||||||
|  | @ -26,6 +26,9 @@ const ( | ||||||
| 	EventTypeUpdate string = "update" | 	EventTypeUpdate string = "update" | ||||||
| 	// EventTypeDelete -- something should be deleted from a user | 	// EventTypeDelete -- something should be deleted from a user | ||||||
| 	EventTypeDelete string = "delete" | 	EventTypeDelete string = "delete" | ||||||
|  | 	// EventTypeStatusUpdate -- something in the user's timeline has been edited | ||||||
|  | 	// (yes this is a confusing name, blame Mastodon) | ||||||
|  | 	EventTypeStatusUpdate string = "status.update" | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| const ( | const ( | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue