Index: /trunk/downstream.go
===================================================================
--- /trunk/downstream.go	(revision 548)
+++ /trunk/downstream.go	(revision 549)
@@ -2031,4 +2031,8 @@
 				return err
 			}
+		case "TARGETS":
+			if err := parseMessageParams(msg, nil, &boundsStr[0], &boundsStr[1], &limitStr); err != nil {
+				return err
+			}
 		default:
 			// TODO: support LATEST, AROUND
@@ -2093,4 +2097,38 @@
 				history, err = store.LoadBeforeTime(uc.network, entity, bounds[0], bounds[1], limit)
 			}
+		case "TARGETS":
+			// TODO: support TARGETS in multi-upstream mode
+			targets, err := store.ListTargets(uc.network, bounds[0], bounds[1], limit)
+			if err != nil {
+				dc.logger.Printf("failed fetching targets for chathistory: %v", target, err)
+				return ircError{&irc.Message{
+					Command: "FAIL",
+					Params:  []string{"CHATHISTORY", "MESSAGE_ERROR", subcommand, "Failed to retrieve targets"},
+				}}
+			}
+
+			batchRef := "history-targets"
+			dc.SendMessage(&irc.Message{
+				Prefix:  dc.srv.prefix(),
+				Command: "BATCH",
+				Params:  []string{"+" + batchRef, "draft/chathistory-targets"},
+			})
+
+			for _, target := range targets {
+				dc.SendMessage(&irc.Message{
+					Tags:    irc.Tags{"batch": irc.TagValue(batchRef)},
+					Prefix:  dc.srv.prefix(),
+					Command: "CHATHISTORY",
+					Params:  []string{"TARGETS", target.Name, target.LatestMessage.UTC().Format(serverTimeLayout)},
+				})
+			}
+
+			dc.SendMessage(&irc.Message{
+				Prefix:  dc.srv.prefix(),
+				Command: "BATCH",
+				Params:  []string{"-" + batchRef},
+			})
+
+			return nil
 		}
 		if err != nil {
Index: /trunk/msgstore.go
===================================================================
--- /trunk/msgstore.go	(revision 548)
+++ /trunk/msgstore.go	(revision 549)
@@ -22,4 +22,9 @@
 }
 
+type chatHistoryTarget struct {
+	Name          string
+	LatestMessage time.Time
+}
+
 // chatHistoryMessageStore is a message store that supports chat history
 // operations.
@@ -27,4 +32,8 @@
 	messageStore
 
+	// ListTargets lists channels and nicknames by time of the latest message.
+	// It returns up to limit targets, starting from start and ending on end,
+	// both excluded. end may be before or after start.
+	ListTargets(network *network, start, end time.Time, limit int) ([]chatHistoryTarget, error)
 	// LoadBeforeTime loads up to limit messages before start down to end. The
 	// returned messages must be between and excluding the provided bounds.
Index: /trunk/msgstore_fs.go
===================================================================
--- /trunk/msgstore_fs.go	(revision 548)
+++ /trunk/msgstore_fs.go	(revision 549)
@@ -7,4 +7,5 @@
 	"os"
 	"path/filepath"
+	"sort"
 	"strings"
 	"time"
@@ -394,9 +395,4 @@
 }
 
-func truncateDay(t time.Time) time.Time {
-	year, month, day := t.Date()
-	return time.Date(year, month, day, 0, 0, 0, 0, t.Location())
-}
-
 func (ms *fsMessageStore) LoadLatestID(network *network, entity, id string, limit int) ([]*irc.Message, error) {
 	var afterTime time.Time
@@ -442,2 +438,90 @@
 	return history[remaining:], nil
 }
+
+func (ms *fsMessageStore) ListTargets(network *network, start, end time.Time, limit int) ([]chatHistoryTarget, error) {
+	rootPath := filepath.Join(ms.root, escapeFilename.Replace(network.GetName()))
+	root, err := os.Open(rootPath)
+	if err != nil {
+		return nil, err
+	}
+
+	// The returned targets are escaped, and there is no way to un-escape
+	// TODO: switch to ReadDir (Go 1.16+)
+	targetNames, err := root.Readdirnames(0)
+	root.Close()
+	if err != nil {
+		return nil, err
+	}
+
+	var targets []chatHistoryTarget
+	for _, target := range targetNames {
+		// target is already escaped here
+		targetPath := filepath.Join(rootPath, target)
+		targetDir, err := os.Open(targetPath)
+		if err != nil {
+			return nil, err
+		}
+
+		entries, err := targetDir.Readdir(0)
+		targetDir.Close()
+		if err != nil {
+			return nil, err
+		}
+
+		// We use mtime here, which may give imprecise or incorrect results
+		var t time.Time
+		for _, entry := range entries {
+			if entry.ModTime().After(t) {
+				t = entry.ModTime()
+			}
+		}
+
+		// The timestamps we get from logs have second granularity
+		t = truncateSecond(t)
+
+		// Filter out targets that don't fullfil the time bounds
+		if !isTimeBetween(t, start, end) {
+			continue
+		}
+
+		targets = append(targets, chatHistoryTarget{
+			Name:          target,
+			LatestMessage: t,
+		})
+	}
+
+	// Sort targets by latest message time, backwards or forwards depending on
+	// the order of the time bounds
+	sort.Slice(targets, func(i, j int) bool {
+		t1, t2 := targets[i].LatestMessage, targets[j].LatestMessage
+		if start.Before(end) {
+			return t1.Before(t2)
+		} else {
+			return !t1.Before(t2)
+		}
+	})
+
+	// Truncate the result if necessary
+	if len(targets) > limit {
+		targets = targets[:limit]
+	}
+
+	return targets, nil
+}
+
+func truncateDay(t time.Time) time.Time {
+	year, month, day := t.Date()
+	return time.Date(year, month, day, 0, 0, 0, 0, t.Location())
+}
+
+func truncateSecond(t time.Time) time.Time {
+	year, month, day := t.Date()
+	return time.Date(year, month, day, t.Hour(), t.Minute(), t.Second(), 0, t.Location())
+}
+
+func isTimeBetween(t, start, end time.Time) bool {
+	if end.Before(start) {
+		end, start = start, end
+	}
+	return start.Before(t) && t.Before(end)
+}
