v0.7.0: mark all as read by clicking the unread badge
- Click a source's unread count in the sidebar to mark all of that source's messages read (clears the badge + reduces the All-sources total); click the All-sources total to mark everything read. - POST /feed/mark-read (session, owner-scoped, excludes trashed). Visible feed rows drop their unread state too. Badge is keyboard-activatable (Enter/Space). 156 tests pass (4 new); inline JS re-validated with node --check. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
7b161f1276
commit
ce63dd1405
7 changed files with 123 additions and 5 deletions
11
CHANGELOG.md
11
CHANGELOG.md
|
|
@ -9,6 +9,14 @@ former name, "Daybook".)
|
||||||
|
|
||||||
_Nothing yet._
|
_Nothing yet._
|
||||||
|
|
||||||
|
## [0.7.0] — 2026-09-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Mark all as read.** Click a source's unread badge in the sidebar to mark
|
||||||
|
all of that source's messages read (clears the badge); click the **All
|
||||||
|
sources** total to mark everything read. Owner-scoped, excludes trashed; any
|
||||||
|
visible feed rows update too. Backed by `POST /feed/mark-read`.
|
||||||
|
|
||||||
## [0.6.3] — 2026-09-07
|
## [0.6.3] — 2026-09-07
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
@ -235,7 +243,8 @@ First tagged release.
|
||||||
- Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a
|
- Dockerised (Gunicorn + whitenoise), SQLite, `git`-based deploy with a
|
||||||
runbook (`deploy/cloud_agent_deploy.md`) and health check.
|
runbook (`deploy/cloud_agent_deploy.md`) and health check.
|
||||||
|
|
||||||
[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.6.3...HEAD
|
[Unreleased]: https://git.cygnus.systems/peter/daybook/compare/v0.7.0...HEAD
|
||||||
|
[0.7.0]: https://git.cygnus.systems/peter/daybook/compare/v0.6.3...v0.7.0
|
||||||
[0.6.3]: https://git.cygnus.systems/peter/daybook/compare/v0.6.2...v0.6.3
|
[0.6.3]: https://git.cygnus.systems/peter/daybook/compare/v0.6.2...v0.6.3
|
||||||
[0.6.2]: https://git.cygnus.systems/peter/daybook/compare/v0.6.1...v0.6.2
|
[0.6.2]: https://git.cygnus.systems/peter/daybook/compare/v0.6.1...v0.6.2
|
||||||
[0.6.1]: https://git.cygnus.systems/peter/daybook/compare/v0.6.0...v0.6.1
|
[0.6.1]: https://git.cygnus.systems/peter/daybook/compare/v0.6.0...v0.6.1
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ urlpatterns = [
|
||||||
path("a/<str:artifact_id>/delete", views.delete_artifact, name="artifact-delete"),
|
path("a/<str:artifact_id>/delete", views.delete_artifact, name="artifact-delete"),
|
||||||
path("a/<str:artifact_id>/undelete", views.undelete_artifact, name="artifact-undelete"),
|
path("a/<str:artifact_id>/undelete", views.undelete_artifact, name="artifact-undelete"),
|
||||||
path("feed/empty-trash", views.empty_trash, name="feed-empty-trash"),
|
path("feed/empty-trash", views.empty_trash, name="feed-empty-trash"),
|
||||||
|
path("feed/mark-read", views.mark_all_read, name="feed-mark-read"),
|
||||||
path("a/<str:artifact_id>", views.artifact_view, name="artifact"),
|
path("a/<str:artifact_id>", views.artifact_view, name="artifact"),
|
||||||
path("s/<str:share_token>", views.shared_artifact_view, name="artifact-shared"),
|
path("s/<str:share_token>", views.shared_artifact_view, name="artifact-shared"),
|
||||||
path("assets/<str:asset_id>", views.asset_view, name="asset"),
|
path("assets/<str:asset_id>", views.asset_view, name="asset"),
|
||||||
|
|
|
||||||
|
|
@ -696,6 +696,21 @@ def empty_trash(request):
|
||||||
return JsonResponse({"purged": count})
|
return JsonResponse({"purged": count})
|
||||||
|
|
||||||
|
|
||||||
|
@login_required
|
||||||
|
@require_POST
|
||||||
|
def mark_all_read(request):
|
||||||
|
"""Mark all of the user's unread (non-trashed) messages as read, optionally
|
||||||
|
scoped to one source. Backs the sidebar 'Mark all as read' action."""
|
||||||
|
unread = Artifact.objects.filter(
|
||||||
|
owner=request.user, deleted_at__isnull=True, read_at__isnull=True
|
||||||
|
)
|
||||||
|
source = request.POST.get("source", "")
|
||||||
|
if source:
|
||||||
|
unread = unread.filter(source=source)
|
||||||
|
marked = unread.update(read_at=timezone.now())
|
||||||
|
return JsonResponse({"marked": marked, "source": source})
|
||||||
|
|
||||||
|
|
||||||
@login_required
|
@login_required
|
||||||
def feed_older(request):
|
def feed_older(request):
|
||||||
"""Return the next page of older day-groups as an HTML fragment for infinite scroll."""
|
"""Return the next page of older day-groups as an HTML fragment for infinite scroll."""
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Single source of truth for the app version. Bump this, commit, then tag the
|
# Single source of truth for the app version. Bump this, commit, then tag the
|
||||||
# commit `vX.Y.Z` and deploy. Surfaced on Settings -> About and in
|
# commit `vX.Y.Z` and deploy. Surfaced on Settings -> About and in
|
||||||
# GET /api/capabilities ("version").
|
# GET /api/capabilities ("version").
|
||||||
__version__ = "0.6.3"
|
__version__ = "0.7.0"
|
||||||
|
|
|
||||||
|
|
@ -514,6 +514,18 @@ h1 {
|
||||||
font-size: 0.7rem;
|
font-size: 0.7rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The unread badge doubles as a "mark all as read" button. */
|
||||||
|
.source-count[data-mark-read] {
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 0 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.source-count[data-mark-read]:hover {
|
||||||
|
background: rgba(74, 110, 168, 0.28);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
.pane-heading span,
|
.pane-heading span,
|
||||||
.artifact-time {
|
.artifact-time {
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
|
|
|
||||||
|
|
@ -37,12 +37,12 @@
|
||||||
<nav class="source-list">
|
<nav class="source-list">
|
||||||
<a class="source-link all-sources-link {% if not selected_source and not selected_folder %}selected{% endif %}" href="?{% if selected_label %}label={{ selected_label|urlencode }}&{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&{% endif %}{% if title_query %}q={{ title_query|urlencode }}&{% endif %}view={{ selected_view }}&preview=none">
|
<a class="source-link all-sources-link {% if not selected_source and not selected_folder %}selected{% endif %}" href="?{% if selected_label %}label={{ selected_label|urlencode }}&{% endif %}{% if selected_kind %}kind={{ selected_kind|urlencode }}&{% endif %}{% if title_query %}q={{ title_query|urlencode }}&{% endif %}view={{ selected_view }}&preview=none">
|
||||||
<span class="source-name">All sources</span>
|
<span class="source-name">All sources</span>
|
||||||
{% if total_count %}<span class="source-count">{{ total_count }}</span>{% endif %}
|
{% if total_count %}<span class="source-count" data-mark-read role="button" tabindex="0" title="Mark all as read">{{ total_count }}</span>{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% for entry in source_counts %}
|
{% for entry in source_counts %}
|
||||||
<a class="source-link {% if entry.source == selected_source and not selected_folder %}selected{% endif %}" href="?source={{ entry.source|urlencode }}{% if selected_label %}&label={{ selected_label|urlencode }}{% endif %}{% if selected_kind %}&kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&q={{ title_query|urlencode }}{% endif %}&view={{ selected_view }}&preview=none">
|
<a class="source-link {% if entry.source == selected_source and not selected_folder %}selected{% endif %}" href="?source={{ entry.source|urlencode }}{% if selected_label %}&label={{ selected_label|urlencode }}{% endif %}{% if selected_kind %}&kind={{ selected_kind|urlencode }}{% endif %}{% if title_query %}&q={{ title_query|urlencode }}{% endif %}&view={{ selected_view }}&preview=none" data-source="{{ entry.source }}">
|
||||||
<span class="source-name">{{ entry.source }}</span>
|
<span class="source-name">{{ entry.source }}</span>
|
||||||
{% if entry.count %}<span class="source-count">{{ entry.count }}</span>{% endif %}
|
{% if entry.count %}<span class="source-count" data-mark-read role="button" tabindex="0" title="Mark all as read">{{ entry.count }}</span>{% endif %}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
@ -620,6 +620,59 @@
|
||||||
openRowMenu(row, event.clientX, event.clientY);
|
openRowMenu(row, event.clientX, event.clientY);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Click a source's unread badge to mark all of that source's messages read.
|
||||||
|
const markSourceRead = async (badge) => {
|
||||||
|
const link = badge.closest(".source-link");
|
||||||
|
if (!link) return;
|
||||||
|
const isAll = link.classList.contains("all-sources-link");
|
||||||
|
const source = link.dataset.source || "";
|
||||||
|
const body = new URLSearchParams();
|
||||||
|
if (!isAll && source) body.set("source", source);
|
||||||
|
try {
|
||||||
|
const response = await fetch("{% url 'feed-mark-read' %}", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {"X-CSRFToken": csrfToken, "Content-Type": "application/x-www-form-urlencoded"},
|
||||||
|
body: body.toString(),
|
||||||
|
});
|
||||||
|
if (!response.ok) return;
|
||||||
|
} catch (e) { return; }
|
||||||
|
if (isAll) {
|
||||||
|
document.querySelectorAll(".source-list .source-count").forEach((b) => b.remove());
|
||||||
|
artifactList.querySelectorAll(".artifact-row.unread").forEach((r) => r.classList.remove("unread"));
|
||||||
|
} else {
|
||||||
|
const n = parseInt(badge.textContent, 10) || 0;
|
||||||
|
badge.remove();
|
||||||
|
const allBadge = document.querySelector(".all-sources-link .source-count");
|
||||||
|
if (allBadge) {
|
||||||
|
const t = (parseInt(allBadge.textContent, 10) || 0) - n;
|
||||||
|
if (t > 0) allBadge.textContent = String(t);
|
||||||
|
else allBadge.remove();
|
||||||
|
}
|
||||||
|
artifactList
|
||||||
|
.querySelectorAll(`.artifact-row.unread[data-source="${CSS.escape(source)}"]`)
|
||||||
|
.forEach((r) => r.classList.remove("unread"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sourceListEl = document.querySelector(".source-list");
|
||||||
|
if (sourceListEl) {
|
||||||
|
const badgeFromEvent = (event) => event.target.closest("[data-mark-read]");
|
||||||
|
sourceListEl.addEventListener("click", (event) => {
|
||||||
|
const badge = badgeFromEvent(event);
|
||||||
|
if (!badge) return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
markSourceRead(badge);
|
||||||
|
});
|
||||||
|
sourceListEl.addEventListener("keydown", (event) => {
|
||||||
|
if (event.key !== "Enter" && event.key !== " ") return;
|
||||||
|
const badge = badgeFromEvent(event);
|
||||||
|
if (!badge) return;
|
||||||
|
event.preventDefault();
|
||||||
|
markSourceRead(badge);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const renderPreview = (row, pushState = true) => {
|
const renderPreview = (row, pushState = true) => {
|
||||||
navigationMode = "artifacts";
|
navigationMode = "artifacts";
|
||||||
markRead(row);
|
markRead(row);
|
||||||
|
|
|
||||||
|
|
@ -128,3 +128,31 @@ class TrashKeepViewTestCase(TestCase):
|
||||||
self.client.post(reverse("artifact-delete", args=[fresh.id]))
|
self.client.post(reverse("artifact-delete", args=[fresh.id]))
|
||||||
self.assertFalse(Artifact.objects.filter(pk=old.id).exists())
|
self.assertFalse(Artifact.objects.filter(pk=old.id).exists())
|
||||||
self.assertTrue(Artifact.objects.filter(pk=fresh.id, deleted_at__isnull=False).exists())
|
self.assertTrue(Artifact.objects.filter(pk=fresh.id, deleted_at__isnull=False).exists())
|
||||||
|
|
||||||
|
def test_mark_all_read_across_sources(self):
|
||||||
|
self.make(source="a")
|
||||||
|
self.make(source="b")
|
||||||
|
r = self.client.post(reverse("feed-mark-read"))
|
||||||
|
self.assertEqual(r.status_code, 200)
|
||||||
|
self.assertEqual(r.json()["marked"], 2)
|
||||||
|
self.assertFalse(Artifact.objects.filter(owner=self.user, read_at__isnull=True).exists())
|
||||||
|
|
||||||
|
def test_mark_all_read_scoped_to_source(self):
|
||||||
|
a = self.make(source="hermes")
|
||||||
|
b = self.make(source="janus")
|
||||||
|
self.assertEqual(self.client.post(reverse("feed-mark-read"), {"source": "hermes"}).json()["marked"], 1)
|
||||||
|
a.refresh_from_db()
|
||||||
|
b.refresh_from_db()
|
||||||
|
self.assertIsNotNone(a.read_at)
|
||||||
|
self.assertIsNone(b.read_at)
|
||||||
|
|
||||||
|
def test_mark_all_read_owner_scoped(self):
|
||||||
|
theirs = self.make(owner=self.other)
|
||||||
|
self.client.post(reverse("feed-mark-read"))
|
||||||
|
theirs.refresh_from_db()
|
||||||
|
self.assertIsNone(theirs.read_at)
|
||||||
|
|
||||||
|
def test_mark_all_read_excludes_trashed(self):
|
||||||
|
a = self.make()
|
||||||
|
Artifact.objects.filter(pk=a.id).update(deleted_at=timezone.now())
|
||||||
|
self.assertEqual(self.client.post(reverse("feed-mark-read")).json()["marked"], 0)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue