package models import ( "errors" "math" "strings" "time" "code.nonshy.com/nonshy/website/pkg/log" "gorm.io/gorm" ) // Comment table - in forum threads, on profiles or photos, etc. type Comment struct { ID uint64 `gorm:"primaryKey"` TableName string `gorm:"index:idx_comment_composite"` TableID uint64 `gorm:"index:idx_comment_composite"` UserID uint64 `gorm:"index"` User User `json:"-"` Message string CreatedAt time.Time `gorm:"index"` UpdatedAt time.Time } // CommentableTables are the set of table names that allow comments (via the // generic "/comments" URI which accepts a table_name param) var CommentableTables = map[string]interface{}{ "photos": nil, "threads": nil, } // SubscribableTables are the set of table names that allow notification subscriptions. var SubscribableTables = map[string]interface{}{ "photos": nil, "threads": nil, // Special case: new photo uploads from your friends. You can't comment on this, // but you can (un)subscribe from it all the same. "friend.photos": nil, } // Preload related tables for the forum (classmethod). func (c *Comment) Preload() *gorm.DB { return DB.Preload("User.ProfilePhoto") } // GetComment by ID. func GetComment(id uint64) (*Comment, error) { c := &Comment{} result := c.Preload().First(&c, id) return c, result.Error } // GetComments queries a set of comment IDs and returns them mapped. func GetComments(IDs []uint64) (map[uint64]*Comment, error) { var ( mt = map[uint64]*Comment{} ts = []*Comment{} ) result := (&Comment{}).Preload().Where("id IN ?", IDs).Find(&ts) for _, row := range ts { mt[row.ID] = row } return mt, result.Error } // AddComment about anything. func AddComment(user *User, tableName string, tableID uint64, message string) (*Comment, error) { c := &Comment{ TableName: tableName, TableID: tableID, User: *user, Message: message, } result := DB.Create(c) return c, result.Error } // CountCommentsByUser returns the total number of comments written by a user. func CountCommentsByUser(user *User, tableName string) int64 { var count int64 result := DB.Where( "table_name = ? AND user_id = ?", tableName, user.ID, ).Model(&Comment{}).Count(&count) if result.Error != nil { log.Error("CountCommentsByUser(%d): %s", user.ID, result.Error) } return count } // CountCommentsReceived returns the total number of comments received on a user's photos. func CountCommentsReceived(user *User) int64 { var count int64 DB.Model(&Comment{}).Joins( "LEFT OUTER JOIN photos ON (comments.table_name = 'photos' AND comments.table_id = photos.id)", ).Where( "comments.table_name = 'photos' AND photos.user_id = ?", user.ID, ).Count(&count) return count } // PaginateComments provides a page of comments on something. // // Note: noBlockLists is to facilitate user-owned forums, where forum owners/moderators should override the block lists // and retain full visibility into all user comments on their forum. Default/recommended is to leave it false, where // the user's block list filters the view. func PaginateComments(user *User, tableName string, tableID uint64, noBlockLists bool, pager *Pagination) ([]*Comment, error) { var ( cs = []*Comment{} query = (&Comment{}).Preload() wheres = []string{} placeholders = []interface{}{} ) wheres = append(wheres, "table_name = ? AND table_id = ?") placeholders = append(placeholders, tableName, tableID) if !noBlockLists { blockedUserIDs := BlockedUserIDs(user) if len(blockedUserIDs) > 0 { wheres = append(wheres, "user_id NOT IN ?") placeholders = append(placeholders, blockedUserIDs) } } // Don't show comments from banned or disabled accounts. wheres = append(wheres, ` EXISTS ( SELECT 1 FROM users WHERE users.id = comments.user_id AND users.status = 'active' ) `) query = query.Where( strings.Join(wheres, " AND "), placeholders..., ).Order(pager.Sort) query.Model(&Comment{}).Count(&pager.Total) result := query.Offset(pager.GetOffset()).Limit(pager.PerPage).Find(&cs) // Inject user relationships into these comments' authors. SetUserRelationshipsInComments(user, cs) return cs, result.Error } // FindPageByComment finds out what page a comment ID exists on for the current user, taking into // account their block lists and comment visibility. // // Note: the comments are assumed ordered by created_at asc. func FindPageByComment(user *User, comment *Comment, pageSize int) (int, error) { var ( allCommentIDs []uint64 blockedUserIDs = BlockedUserIDs(user) wheres = []string{} placeholders = []interface{}{} ) // Get the complete set of comment IDs that this comment is on a thread of. wheres = append(wheres, "table_name = ? AND table_id = ?") placeholders = append(placeholders, comment.TableName, comment.TableID) if len(blockedUserIDs) > 0 { wheres = append(wheres, "user_id NOT IN ?") placeholders = append(placeholders, blockedUserIDs) } result := DB.Table( "comments", ).Select( "id", ).Where( strings.Join(wheres, " AND "), placeholders..., ).Order("created_at asc").Scan(&allCommentIDs) if result.Error != nil { return 0, result.Error } // Scan the comment thread to find it. for i, cid := range allCommentIDs { if cid == comment.ID { var page = int(math.Ceil(float64(i) / float64(pageSize))) // If the comment index is an equal multiple of the page size // (e.g. comment #20 is the 1st comment on page 2, since 0-19 is page 1), // account for an off-by-one error. if i%pageSize == 0 { page++ } if page == 0 { page = 1 } return page, nil } } return -1, errors.New("comment not visible to current user") } // ListComments returns a complete set of comments without paging. func ListComments(user *User, tableName string, tableID uint64) ([]*Comment, error) { var ( cs []*Comment blockedUserIDs = BlockedUserIDs(user) wheres = []string{} placeholders = []interface{}{} ) wheres = append(wheres, "table_name = ? AND table_id = ?") placeholders = append(placeholders, tableName, tableID) if len(blockedUserIDs) > 0 { wheres = append(wheres, "user_id NOT IN ?") placeholders = append(placeholders, blockedUserIDs) } // Don't show comments from banned or disabled accounts. wheres = append(wheres, ` EXISTS ( SELECT 1 FROM users WHERE users.id = comments.user_id AND users.status = 'active' ) `) result := (&Comment{}).Preload().Where( strings.Join(wheres, " AND "), placeholders..., ).Order("created_at asc").Find(&cs) return cs, result.Error } // Save a comment. func (c *Comment) Save() error { return DB.Save(c).Error } // Delete a comment. func (c *Comment) Delete() error { return DB.Delete(c).Error } // IsEdited returns if a comment was reasonably edited after it was created. func (c *Comment) IsEdited() bool { return c.UpdatedAt.Sub(c.CreatedAt) > 5*time.Second } type CommentCountMap map[uint64]int64 // MapCommentCounts collects total numbers of comments over a set of table IDs. Returns a // map of table ID (uint64) to comment counts for each (int64). func MapCommentCounts(tableName string, tableIDs []uint64) CommentCountMap { var result = CommentCountMap{} // Initialize the result set. for _, id := range tableIDs { result[id] = 0 } // Hold the result of the grouped count query. type group struct { ID uint64 Comments int64 } var groups = []group{} // Map the counts of comments to each of these IDs. if res := DB.Table( "comments", ).Select( "table_id AS id, count(id) AS comments", ).Where( "table_name = ? AND table_id IN ?", tableName, tableIDs, ).Group("table_id").Scan(&groups); res.Error != nil { log.Error("MapCommentCounts: count query: %s", res.Error) } // Map the counts back in. for _, row := range groups { result[row.ID] = row.Comments } return result } // Get a comment count for the given table ID from the map. func (cc CommentCountMap) Get(id uint64) int64 { if value, ok := cc[id]; ok { return value } return 0 }