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