diff --git a/conference.go b/conference.go
index b2f6266..0d9bc64 100644
--- a/conference.go
+++ b/conference.go
@@ -100,6 +100,9 @@ func Topics(ctxt ui.AmContext) (string, any, error) {
return ui.ErrorPage(ctxt, err)
}
+ traverser := ui.NewTopicTraverser(topics)
+ ctxt.SetSession("topic.traverser", traverser)
+
tz := prefs.Location()
loc := prefs.Localizer()
fdate := make([]string, len(topics))
@@ -107,10 +110,17 @@ func Topics(ctxt ui.AmContext) (string, any, error) {
fdate[i] = loc.Strftime("%x %X", t.LastUpdate.In(tz))
}
+ // create the "read new" URL
+ urlStem := fmt.Sprintf("/comm/%s/conf/%s", comm.Alias, ctxt.URLParam("confid"))
+ firstTopic := traverser.FirstTopic()
+ if firstTopic >= 1 {
+ ctxt.VarMap().Set("urlReadNew", fmt.Sprintf("%s/r/%d", urlStem, firstTopic))
+ }
+
ctxt.VarMap().Set("canCreate", conf.TestPermission("Conference.Create", myLevel))
ctxt.VarMap().Set("conferenceName", conf.Name)
ctxt.VarMap().Set("urlBack", fmt.Sprintf("/comm/%s/conf", comm.Alias))
- ctxt.VarMap().Set("urlStem", fmt.Sprintf("/comm/%s/conf/%s", comm.Alias, ctxt.URLParam("confid")))
+ ctxt.VarMap().Set("urlStem", urlStem)
ctxt.VarMap().Set("permalink", "TODO")
ctxt.VarMap().Set("view", view)
ctxt.VarMap().Set("sort", sort)
@@ -433,6 +443,12 @@ func templateOverrideLink(args jet.Arguments) reflect.Value {
* Standard Go error status.
*/
func ReadPosts(ctxt ui.AmContext) (string, any, error) {
+ // Extract the traverser first, so we can unclear it in background tasks.
+ var traverser ui.TopicTraverser = nil
+ if ctxt.IsSession("topic.traverser") {
+ traverser = ctxt.GetSession("topic.traverser").(ui.TopicTraverser)
+ }
+
// If we need to reset a topic's last read count (as with "Next & Keep New"), spin the task off.
if ctxt.HasParameter("rst") {
rst := strings.Split(ctxt.Parameter("rst"), ",")
@@ -445,6 +461,9 @@ func ReadPosts(ctxt ui.AmContext) (string, any, error) {
topic, _ := database.AmGetTopic(ctx, int32(topicId))
if topic != nil {
topic.SetLastRead(ctx, user, int32(lastRead))
+ if traverser != nil && int32(lastRead) < topic.TopMessage {
+ traverser.UnclearTopic(topic.Number)
+ }
}
}
})
@@ -567,6 +586,18 @@ func ReadPosts(ctxt ui.AmContext) (string, any, error) {
}
ctxt.VarMap().Set("advancedControls", advancedControls)
+ // Adjust the traverser and get the "next" link.
+ urlStem := fmt.Sprintf("/comm/%s/conf/%s", comm.Alias, ctxt.GetScratch("currentAlias").(string))
+ if traverser != nil {
+ traverser.ClearTopic(topic.Number)
+ nextTopic := traverser.NextTopic(topic.Number)
+ if nextTopic >= 0 {
+ nextTopicURL := fmt.Sprintf("%s/r/%d", urlStem, nextTopic)
+ ctxt.VarMap().Set("urlNextTopic", nextTopicURL)
+ ctxt.VarMap().Set("urlNextKeepNew", fmt.Sprintf("%s?rst=%d,%d", nextTopicURL, topic.TopicId, lastRead))
+ }
+ }
+
// Render the output.
ctxt.VarMap().Set("amsterdam_pageTitle", fmt.Sprintf("%s: %s", topic.Name, summaryLine))
ctxt.VarMap().Set("topicName", topic.Name)
@@ -578,7 +609,7 @@ func ReadPosts(ctxt ui.AmContext) (string, any, error) {
ctxt.VarMap().SetFunc("post_getOverrideLink", templateOverrideLink)
ctxt.VarMap().SetFunc("post_getText", templatePostText)
ctxt.VarMap().SetFunc("post_getUserName", templateExtractUserName)
- ctxt.VarMap().Set("post_stem", fmt.Sprintf("/comm/%s/conf/%s/r/%d", comm.Alias, ctxt.GetScratch("currentAlias").(string), topic.Number))
+ ctxt.VarMap().Set("post_stem", fmt.Sprintf("%s/r/%d", urlStem, topic.Number))
ctxt.VarMap().Set("post_max", topic.TopMessage)
ctxt.VarMap().Set("post_topicPermalink", fmt.Sprintf("/go/%s", topicPostRef))
ctxt.VarMap().Set("posts", posts)
@@ -586,7 +617,7 @@ func ReadPosts(ctxt ui.AmContext) (string, any, error) {
ctxt.VarMap().Set("pin", pin)
ctxt.VarMap().Set("rangeEnd", postRange[1])
ctxt.VarMap().Set("rangeStart", postRange[0])
- ctxt.VarMap().Set("topicListLink", fmt.Sprintf("/comm/%s/conf/%s", comm.Alias, ctxt.GetScratch("currentAlias").(string)))
+ ctxt.VarMap().Set("topicListLink", urlStem)
ctxt.VarMap().Set("topicNum", topic.Number)
if resetLastRead {
user := ctxt.CurrentUser()
diff --git a/ui/topic_traverser.go b/ui/topic_traverser.go
new file mode 100644
index 0000000..1aad06d
--- /dev/null
+++ b/ui/topic_traverser.go
@@ -0,0 +1,100 @@
+/*
+ * Amsterdam Web Communities System
+ * Copyright (c) 2025-2026 Erbosoft Metaverse Design Solutions, All Rights Reserved
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
+ */
+
+// Package ui holds the support for the Amsterdam user interface, wrapping Echo and Jet templates.
+package ui
+
+import (
+ "sync"
+
+ "git.erbosoft.com/amy/amsterdam/database"
+ "github.com/bits-and-blooms/bitset"
+)
+
+// TopicTraverser is the data structure that allows us to navigate to "next" topics.
+type TopicTraverser interface {
+ FirstTopic() int16
+ NextTopic(int16) int16
+ ClearTopic(int16)
+ UnclearTopic(int16)
+}
+
+// topicTraverser is the internal data structure that implements TopicTraverser.
+type topicTraverser struct {
+ lock sync.RWMutex
+ topics []int16
+ active *bitset.BitSet
+}
+
+// NewTopicTraverser creates the traverser data structure from the topic listing.
+func NewTopicTraverser(topics []*database.TopicSummary) TopicTraverser {
+ trav := topicTraverser{
+ topics: make([]int16, 0, len(topics)),
+ active: bitset.New(uint(len(topics))),
+ }
+ p := 0
+ for _, t := range topics {
+ if t.Unread > 0 {
+ trav.topics = append(trav.topics, t.Number)
+ trav.active.Set(uint(p))
+ p++
+ }
+ }
+ return &trav
+}
+
+// FirstTopic returns the first unread topic number in the traverser.
+func (trav *topicTraverser) FirstTopic() int16 {
+ trav.lock.RLock()
+ defer trav.lock.RUnlock()
+ i, b := trav.active.NextSet(0)
+ if b {
+ return trav.topics[i]
+ }
+ return -1
+}
+
+// NextTopic returns the unread topic number in the traverser after the specified one.
+func (trav *topicTraverser) NextTopic(cur int16) int16 {
+ trav.lock.RLock()
+ defer trav.lock.RUnlock()
+ seeking := false
+ for i, v := range trav.topics {
+ if v == cur {
+ seeking = true
+ } else if seeking && trav.active.Test(uint(i)) {
+ return v
+ }
+ }
+ return trav.FirstTopic() // look from the beginning
+}
+
+// ClearTopic clears the specified topic number from the traverser.
+func (trav *topicTraverser) ClearTopic(num int16) {
+ trav.lock.Lock()
+ defer trav.lock.Unlock()
+ for i, v := range trav.topics {
+ if v == num {
+ trav.active.Clear(uint(i))
+ return
+ }
+ }
+}
+
+// UnclearTopic restores the specified topic number to the traverser.
+func (trav *topicTraverser) UnclearTopic(num int16) {
+ trav.lock.Lock()
+ defer trav.lock.Unlock()
+ for i, v := range trav.topics {
+ if v == num {
+ trav.active.Set(uint(i))
+ return
+ }
+ }
+}
diff --git a/ui/views/posts.jet b/ui/views/posts.jet
index 45f1bb9..a460549 100644
--- a/ui/views/posts.jet
+++ b/ui/views/posts.jet
@@ -24,13 +24,13 @@
class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded text-sm font-medium transition-colors">
{{ if isTopicHidden }}Show Topic{{ else }}Hide Topic{{ end }}
- {{ if false }}{* TODO *}
- Next Topic
- {{ if false }}{* TODO *}
- Next & Keep New
- {{ end }}
+ {{ end }}
+ {{ if isset(urlNextKeepNew) }}
+ Next & Keep New
{{ end }}
Find
diff --git a/ui/views/topiclist.jet b/ui/views/topiclist.jet
index bcc983f..0991b20 100644
--- a/ui/views/topiclist.jet
+++ b/ui/views/topiclist.jet
@@ -26,6 +26,12 @@
Add Topic
{{ end }}
+ {{ if isset(urlReadNew) }}
+
+ Read New
+
+ {{ end }}
Find