From 57d664dcb1a78963e80fb6398cadce5530e4e72c Mon Sep 17 00:00:00 2001 From: Amy Gale Ruth Bowersox Date: Thu, 18 Dec 2025 23:43:51 -0700 Subject: [PATCH] partial implementation of "read posts" --- conference.go | 86 +++++++++++++++++++++++++++++++++++++++++++++++ database/topic.go | 50 ++++++++++++++++++++++++++- main.go | 1 + ui/amcontext.go | 16 +++++++++ 4 files changed, 152 insertions(+), 1 deletion(-) diff --git a/conference.go b/conference.go index ccdb288..6bfb0db 100644 --- a/conference.go +++ b/conference.go @@ -17,6 +17,7 @@ import ( "mime/multipart" "net/http" "strconv" + "strings" "git.erbosoft.com/amy/amsterdam/database" "git.erbosoft.com/amy/amsterdam/htmlcheck" @@ -322,3 +323,88 @@ func AttachmentUpload(ctxt ui.AmContext) (string, any, error) { } return ui.ErrorPage(ctxt, errors.New("invalid button clicked on form")) } + +func ReadPosts(ctxt ui.AmContext) (string, any, error) { + // If we need to reset a topic's last read count (as with "Next & Keep New"), spin the task off into a goroutine. + if ctxt.HasParameter("rst") { + rst := strings.Split(ctxt.Parameter("rst"), ",") + if len(rst) >= 2 { + topicId, e1 := strconv.ParseInt(rst[0], 10, 32) + lastRead, e2 := strconv.ParseInt(rst[1], 10, 32) + if e1 == nil && e2 == nil { + user := ctxt.CurrentUser() + go func() { + topic, _ := database.AmGetTopic(int32(topicId)) + if topic != nil { + topic.SetLastRead(user, int32(lastRead)) + } + }() + } + } + } + // Locate community, conference, and topic. + comm := ctxt.CurrentCommunity() + conf := ctxt.GetScratch("currentConference").(*database.Conference) + var topic *database.Topic = nil + if rawTopic, err := strconv.ParseInt(ctxt.URLParam("topic"), 10, 16); err == nil { + topic, err = database.AmGetTopicByNumber(conf, int16(rawTopic)) + } + if topic == nil { + ctxt.SetRC(http.StatusNotFound) + return ui.ErrorPage(ctxt, fmt.Errorf("topic not found: %s", ctxt.URLParam("topic"))) + } + + // Determine the range of posts to display. The "pin" is the post number after which we display the horizontal line separating old and new posts. + postRange := make([]int32, 2) + var pin int32 = -1 + if ctxt.HasParameter("r") { + rstr := strings.Split(ctxt.Parameter("r"), ",") + if len(rstr) == 0 { + ctxt.SetRC(http.StatusNotFound) + return ui.ErrorPage(ctxt, fmt.Errorf("posts not found: %s in topic %d", ctxt.Parameter("r"), topic.Number)) + } + v, err := strconv.ParseInt(rstr[0], 10, 32) + if err != nil { + ctxt.SetRC(http.StatusNotFound) + return ui.ErrorPage(ctxt, fmt.Errorf("posts not found: %s in topic %d", ctxt.Parameter("r"), topic.Number)) + } + postRange[0] = int32(v) + if len(rstr) > 1 { + v, err = strconv.ParseInt(rstr[1], 10, 32) + if err != nil { + ctxt.SetRC(http.StatusNotFound) + return ui.ErrorPage(ctxt, fmt.Errorf("posts not found: %s in topic %d", ctxt.Parameter("r"), topic.Number)) + } + postRange[1] = int32(v) + } else { + postRange[1] = postRange[0] + } + if postRange[1] < 0 { + postRange[1] = topic.TopMessage + } + if postRange[0] > postRange[1] { + t := postRange[0] + postRange[0] = postRange[1] + postRange[1] = t + } + } else { + lastRead, err := topic.GetLastRead(ctxt.CurrentUser()) + if err != nil { + ctxt.SetRC(http.StatusNotFound) + return ui.ErrorPage(ctxt, fmt.Errorf("posts not found in topic %d - %v", topic.Number, err)) + } + postRange[0] = lastRead + 1 + postRange[1] = topic.TopMessage + count := postRange[1] - postRange[0] + 1 + if count > ctxt.Globals().PostsPerPage { + postRange[0] = postRange[1] - ctxt.Globals().PostsPerPage + 1 + } else if count < ctxt.Globals().PostsPerPage { + pin = postRange[0] - 1 + postRange[0] -= ctxt.Globals().OldPostsAtTop + postRange[0] = max(0, postRange[0]) + if pin < postRange[0] { + pin = -1 + } + } + } +} diff --git a/database/topic.go b/database/topic.go index 77d10fc..0e9cfe0 100644 --- a/database/topic.go +++ b/database/topic.go @@ -52,6 +52,31 @@ func (t *Topic) GetPost(num int32) (*PostHeader, error) { return nil, err } +// GetLastRead returns the "last read" message for a user on a topic. +func (t *Topic) GetLastRead(u *User) (int32, error) { + rs, err := amdb.Query("SELECT last_message FROM topicsettings WHERE topicid = ? AND uid = ?", t.TopicId, u.Uid) + if err != nil { + return -1, err + } + var rc int32 = -1 + if rs.Next() { + rs.Scan(&rc) + } + return rc, nil +} + +// SetLastRead sets the "last read" message for a user on a topic. +func (t *Topic) SetLastRead(u *User, postNum int32) error { + rs, err := amdb.Exec("UPDATE topicsettings SET last_message = ?, last_read = NOW() WHERE topicid = ? AND uid = ?", postNum, t.TopicId, u.Uid) + if err == nil { + nrow, _ := rs.RowsAffected() + if nrow == 0 { + _, err = amdb.Exec("INSERT INTO topicsettings (topicid, uid, last_message, last_read, last_post) VALUES (?, ?, ?, NOW(), NULL)", t.TopicId, u.Uid, postNum) + } + } + return err +} + // TopicSettings contains per-user settings for topics, including the "last read" message pointer. type TopicSettings struct { TopicId int32 `db:"topicid"` // unique ID of the topic @@ -99,7 +124,7 @@ func AmGetTopic(topicId int32) (*Topic, error) { return &(dbdata[0]), nil } -/* AmGetTopic retrieves a topic by ID, in a transaction. +/* AmGetTopicTx retrieves a topic by ID, in a transaction. * Parameters: * tx - The transaction to use. * topicId - ID of the topic to retrieve. @@ -122,6 +147,29 @@ func AmGetTopicTx(tx *sqlx.Tx, topicId int32) (*Topic, error) { return &(dbdata[0]), nil } +/* AmGetTopicByNumber retrieves a topic by conference and sequence number. + * Parameters: + * conf - The conference to look in. + * topicNum - The topic number within that conference. + * Returns: + * Pointer to the Topic, or nil. + * Standard Go error status. + */ +func AmGetTopicByNumber(conf *Conference, topicNum int16) (*Topic, error) { + var dbdata []Topic + err := amdb.Select(&dbdata, "SELECT * FROM topics WHERE confid = ? AND num = ?", conf.ConfId, topicNum) + if err == nil { + if len(dbdata) == 0 { + err = fmt.Errorf("no topic numbered %d in conference %s (#%d)", topicNum, conf.Name, conf.ConfId) + } else if len(dbdata) > 1 { + err = fmt.Errorf("AmGetTopicByNumber: too many entries (%d) for topic #%d in conference %s (#%d)", len(dbdata), topicNum, conf.Name, conf.ConfId) + } else { + return &(dbdata[0]), nil + } + } + return nil, err +} + // View and sort constants for AmListTopics. const ( TopicViewAll = 0 // list all topics diff --git a/main.go b/main.go index 81cd2f3..d6827d7 100644 --- a/main.go +++ b/main.go @@ -97,6 +97,7 @@ func setupEcho() *echo.Echo { confGroup.GET("", ui.AmWrap(Topics)) confGroup.GET("/new_topic", ui.AmWrap(NewTopicForm)) confGroup.POST("/new_topic", ui.AmWrap(NewTopic)) + confGroup.GET("/r/:topic", ui.AmWrap(ReadPosts)) return e } diff --git a/ui/amcontext.go b/ui/amcontext.go index 4bbbff1..12e2fe3 100644 --- a/ui/amcontext.go +++ b/ui/amcontext.go @@ -48,6 +48,7 @@ type AmContext interface { FormFile(string) (*multipart.FileHeader, error) Globals() *database.Globals GlobalFlags() *util.OptionSet + HasParameter(string) bool IsMember() bool IsMemberLocked() bool LeftMenu() string @@ -206,6 +207,21 @@ func (c *amContext) GlobalFlags() *util.OptionSet { return c.globalFlags } +// HasParameter tests to see if we have a parameter. +func (c *amContext) HasParameter(name string) bool { + s := c.echoContext.QueryParam(name) + if s != "" { + return true + } + if c.echoContext.Request().Method == "POST" { + s = c.echoContext.FormValue(name) + if s != "" { + return true + } + } + return false +} + // IsMember returns true if the user is a member of the current community. func (c *amContext) IsMember() bool { return c.isMember