8000 feat(timeline): snooze a source, person or kind for an hour or a week as well as a day by adeelahmad · Pull Request #660 · adeelahmad/lense · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/timeline-snooze-lengths.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
The mute menu on a timeline item now snoozes a source, person or kind for an hour or a week as well as a day.
2 changes: 1 addition & 1 deletion docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ request, to another check that proves the same thing; loosening a requirement or
| The graph as it was at any version | [test_graph_history.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_graph_history.py) `test_the_explorer_and_cypher_as_of_a_version`, `test_the_canvas_as_of_a_version`; [test_graph_history.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/domain/test_graph_history.py) `test_every_curation_is_an_event_and_every_version_replays` |
| Sources, sensors, people and agents as actors in the graph, with ADDED links to what they brought in, on the canvas, in the explorer and in Cypher | [test_graph.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_graph.py) `test_sources_are_actors_in_the_graph`; jest [graph-explorer.test.ts](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/graph-explorer.test.ts) `shows a source only while something it brought in is visible` |
| A For you order of the timeline, learned from each person's own reactions, comments and marks, each item saying why, with Latest the default; through the API, MCP, the assistant and the page | [test_timeline_for_you.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_timeline_for_you.py) `test_for_you_learns_from_reactions_comments_and_marks_and_says_why`, `test_agents_read_for_you_too`; jest [timeline-model.test.ts](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/timeline-model.test.ts) `keep the For you order as a view, not a filter`, [timeline-ui.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/timeline-ui.test.tsx) `For you: the toggle goes in the address, each placed line says why, and Latest stays the default` |
| Muting a source, a person or a kind on the timeline, for good or until a time, for the person alone, with nothing deleted; through the API, MCP, the assistant (asking first after outside text) and the page | [test_timeline_mutes.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_timeline_mutes.py) `test_a_mute_hides_an_actor_or_a_kind_for_the_person_alone`, `test_agents_mute_too_and_the_assistant_asks_first_after_outside_text`; jest [timeline-ui.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/timeline-ui.test.tsx) `muting a source takes everything from it out, lists it as muted, and Undo or the chip brings it back`, `the menu beside Mute snoozes a source for a day, or mutes the item's kind` |
| Muting a source, a person or a kind on the timeline, for good or until a time, for the person alone, with nothing deleted; through the API, MCP, the assistant (asking first after outside text) and the page | [test_timeline_mutes.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_timeline_mutes.py) `test_a_mute_hides_an_actor_or_a_kind_for_the_person_alone`, `test_agents_mute_too_and_the_assistant_asks_first_after_outside_text`; jest [timeline-ui.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/timeline-ui.test.tsx) `muting a source takes everything from it out, lists it as muted, and Undo or the chip brings it back`, `the menu beside Mute snoozes a source for an hour, a day or a week, or mutes the item's kind`, [timeline-model.test.ts](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/timeline-model.test.ts) `a snooze ends an hour, a day or a week from now` |
| An asset moved to another namespace is `asset.moved`, a Move seen where it is now, and a sensor a person pauses, ignores, resumes or sets back to new is `sensor.changed`, an Update with the status it had before, seen in its namespace or by admins; a change that changes nothing logs nothing | [test_timeline_verbs.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_timeline_verbs.py) `test_moving_an_asset_is_a_move_seen_where_it_is_now`, `test_switching_a_sensor_is_an_update_with_what_it_was` |
| A file a watched folder finds changed in its source and reads again is `file.changed`, an Update linking the asset it was before, not a second `file.added`; it is still a child of `asset.added`, so triggers on new assets fire on it as before | [test_events_content.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/domain/test_events_content.py) `test_a_file_changed_in_its_source_is_file_changed_linked_to_the_asset_it_was_before` |
| An opened timeline item shows what it caused, the runs it started of the automations the person manages and the approvals those asked for, with Approve and Decline on waiting ones; `GET /activities/{id}/caused`, `approvals?activity=`, MCP `activity_caused` and the assistant | [test_timeline_caused.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_timeline_caused.py) `test_an_item_lists_what_it_caused_and_the_waiting_approval_is_decided_there`, `test_agents_read_what_an_item_caused_too`; jest [timeline-ui.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/timeline-ui.test.tsx) `an opened item shows what it caused, and a waiting approval is approved there` |
Expand Down
8 changes: 4 additions & 4 deletions docs/timeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,14 +199,14 @@ block.
## Muting

Each person mutes a source or person (an actor) or a kind of activity ("hide syslog"), for good or until a time
(snoozed for a day), for themselves alone. Muting hides; it never deletes: a muted actor's activities stay stored,
(snoozed for an hour, a day or a week), for themselves alone. Muting hides; it never deletes: a muted actor's activities stay stored,
still trigger automations and still show to everyone else, and come back the moment the mute is lifted or runs out.
The timeline leaves them out as it leaves out what you hid, so `hidden=true` shows them and `hidden=false` (the
page) does not; a For you page scores only what is left.

- On the page, **Mute Work Gmail** on an opened item mutes its actor with one click; the small menu beside it has
**Mute Work Gmail for a day** (a snooze: the mute runs out by itself), **Mute Email received items** (the kind) and
the kind for a day. The **Muted** row above the list shows each, with when a snooze ends, and lifts one with its ×.
**Mute Work Gmail for an hour**, **for a day** and **for a week** (a snooze: the mute runs out by itself), **Mute
Email received items** (the kind) and the kind for each of those. The **Muted** row above the list shows each, with when a snooze ends, and lifts one with its ×.
Any other time is through the API, MCP and the assistant.
- `POST /api/v1/activities/mute` with `{"actor": "lens:source/12", "name": "Work Gmail"}` or `{"kind":
"sensor.reading"}` (the kinds under it count too), and `"until"` as an ISO 8601 time to snooze it, mutes for
Expand Down Expand Up @@ -275,7 +275,7 @@ per source page; they go when the person is removed. Nothing of this costs a mod
- Server-sent updates instead of the page polling for new items.
- For you counts opens too, once the page reports them, and could cache each person's affinity for a minute rather
than summing it on every read.
- A muted actor's own line in the Filters, and a snooze of your own length from the page.
- A muted actor's own line in the Filters, and a snooze to a time you pick from the page (the API takes any `until`).
- A file changed in its source is read again as a new asset beside the earlier one (`file.changed` links them);
reading the change into the same asset is to come. A file in a namespace's own folders changed in place is read
again into the same asset with no activity yet.
Expand Down
8 changes: 8 additions & 0 deletions nextjs-frontend/__tests__/timeline-model.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
NO_FILTERS,
objectFields,
objectHref,
snoozeUntil,
toParams,
toQuery,
unseen,
Expand Down Expand Up @@ -186,3 +187,10 @@ test("a saved view keeps the filters and order as they are, and offers a name fr
expect(viewName(f, { "email.received": "Email received" })).toBe("work · Email received · Important · For you");
expect(viewName(NO_FILTERS)).toBe("My view");
});

test("a snooze ends an hour, a day or a week from now", () => {
const now = Date.parse("2026-10-19T09:00:00Z");
expect(snoozeUntil("an hour", now)).toBe("2026-10-19T10:00:00.000Z");
expect(snoozeUntil("a day", now)).toBe("2026-10-20T09:00:00.000Z");
expect(snoozeUntil("a week", now)).toBe("2026-10-26T09:00:00.000Z");
});
7 changes: 5 additions & 2 deletions nextjs-frontend/__tests__/timeline-ui.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -599,7 +599,7 @@ test("an opened item shows what it caused, and a waiting approval is approved th
expect(await screen.findByText("Done")).toBeInTheDocument();
});

test("the menu beside Mute snoozes a source for a day, or mutes the item's kind", async () => {
test("the menu beside Mute snoozes a source for an hour, a day or a week, or mutes the item's kind", async () => {
wrap(<TimelinePage />);
const rows = within(await screen.findByRole("list", { name: "Timeline" })).getAllByTestId("timeline-row");
// the kind, named as the page names it, for good: the upload's line goes
Expand All @@ -616,7 +616,10 @@ test("the menu beside Mute snoozes a source for a day, or mutes the item's kind"
fireEvent.click(within(rows[0]).getByRole("button", { name: "Show the 3" }));
const more = within(rows[0]).getAllByRole("button", { name: "More ways to mute Work Gmail" });
fireEvent.keyDown(more[0], { key: "Enter" });
fireEvent.click(await screen.findByRole("menuitem", { name: "Mute Work Gmail for a day" }));
expect(await screen.findByRole("menuitem", { name: "Mute Work Gmail for an hour" })).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "Mute Work Gmail for a week" })).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "Mute Email received items for a week" })).toBeInTheDocument();
fireEvent.click(screen.getByRole("menuitem", { name: "Mute Work Gmail for a day" }));
await waitFor(() =>
expect(m(Activities.muteActivities)).toHaveBeenLastCalledWith(
expect.objectContaining({
Expand Down
25 changes: 16 additions & 9 deletions nextjs-frontend/components/timeline/item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,12 @@ import {
groupSummary,
objectFields,
objectHref,
SNOOZES,
type Activity,
type Family,
type Group,
type Mark,
type Snooze,
} from "@/components/timeline/model";
import { SocialSummary } from "@/components/timeline/social";
import { sumReactions } from "@/components/timeline/social-model";
Expand Down Expand Up @@ -202,10 +204,11 @@ export function ActivityDetail({
);
}

/** How to mute from an item: its actor (the default) or its kind, for good or for a day. */
export type MuteOpts = { kind?: boolean; day?: boolean };
/** How to mute from an item: its actor (the default) or its kind, for good or for a snooze (an hour, a day, a week). */
export type MuteOpts = { kind?: boolean; span?: Snooze };
const SNOOZE_SPANS = Object.keys(SNOOZES) as Snooze[];

/** Mute the item's actor with one click, and the other ways (for a day, its kind) behind a small menu beside it. */
/** Mute the item's actor with one click, and the other ways (a snooze, its kind) behind a small menu beside it. */
function MuteButton({ name, kind, onMute }: { name: string; kind?: string; onMute: (opts?: MuteOpts) => void }) {
return (
<span className="inline-flex items-center">
Expand All @@ -225,17 +228,21 @@ function MuteButton({ name, kind, onMute }: { name: string; kind?: string; onMut
</IconButton>
</MenuTrigger>
<MenuContent align="start">
<MenuItem icon={<Clock />} => onMute({ day: true })}>
Mute {name} for a day
</MenuItem>
{SNOOZE_SPANS.map((span) => (
<MenuItem key={span} icon={<Clock />} => onMute({ span })}>
Mute {name} for {span}
</MenuItem>
))}
{kind && (
<>
<MenuItem icon={<VolumeX />} => onMute({ kind: true })}>
Mute {kind} items
</MenuItem>
<MenuItem icon={<Clock />} => onMute({ kind: true, day: true })}>
Mute {kind} items for a day
</MenuItem>
{SNOOZE_SPANS.map((span) => (
<MenuItem key={span} icon={<Clock />} => onMute({ kind: true, span })}>
Mute {kind} items for {span}
</MenuItem>
))}
</>
)}
</MenuContent>
Expand Down
10 changes: 10 additions & 0 deletions nextjs-frontend/components/timeline/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -416,3 +416,13 @@ export function viewName(f: Filters, labels: Record<string, string> = {}): strin
];
return (parts.join(" · ") || "My view").slice(0, 60);
}

// ---------- snoozes ----------
/** How long a snooze from the page lasts, by how the menu says it ("Mute Work Gmail for a week"). */
export const SNOOZES = { "an hour": 3_600_000, "a day": 86_400_000, "a week": 7 * 86_400_000 } as const;
export type Snooze = keyof typeof SNOOZES;

/** A snooze's end from now, as the `until` of a mute. */
export function snoozeUntil(span: Snooze, now = Date.now()): string {
return new Date(now + SNOOZES[span]).toISOString();
}
4 changes: 2 additions & 2 deletions nextjs-frontend/components/timeline/timeline-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
import { Social } from "@/components/timeline/social";
import { useCommentCounts, useReactions } from "@/components/timeline/use-social";
import { ViewsMenu } from "@/components/timeline/views";
import { aDay, useKinds, useMarks, useMutes, useNewItems, useTimeline } from "@/components/timeline/use-timeline";
import { useKinds, useMarks, useMutes, useNewItems, useTimeline } from "@/components/timeline/use-timeline";
import { Button } from "@/components/ui/button";
import { Segmented } from "@/components/ui/tabs";
import { Drawer } from "@/components/ui/dialog";
Expand Down Expand Up @@ -222,7 +222,7 @@ export function TimelinePage() {
(a: Activity, opts?: MuteOpts) =>
mutes.mute(
opts?.kind ? { kind: a.kind, name: labels[a.kind] ?? a.kind } : { actor: a.actor.id, name: a.actor.name },
opts?.day ? aDay() : undefined,
opts?.span,
),
[mutes, labels],
);
Expand Down
24 changes: 14 additions & 10 deletions nextjs-frontend/components/timeline/use-timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,16 @@ import type {
Mute,
MutesOut,
} from "@/app/openapi-client/types.gen";
import { markOf, toQuery, type Activity, type Filters, type ListQuery, type Mark } from "@/components/timeline/model";
import {
markOf,
snoozeUntil,
toQuery,
type Activity,
type Filters,
type ListQuery,
type Mark,
type Snooze,
} from "@/components/timeline/model";
import { useToast } from "@/components/ui/toast";
import { data, useApiClient } from "@/lib/api/browser";

Expand Down Expand Up @@ -130,13 +139,8 @@ export function useMarks(server: Record<string, ActivityMark | undefined>) {

export type { Activity };

/** A day from now, as the `until` of a mute that is a snooze. */
export function aDay(now = Date.now()): string {
return new Date(now + 86_400_000).toISOString();
}

/** The person's mutes (docs/timeline.md, Muting): the sources, people and kinds the timeline leaves out for them.
* `mute` mutes an actor (with its name, for the chips) or a kind, for good or `until` a time, says so with an Undo,
* `mute` mutes an actor (with its name, for the chips) or a kind, for good or for a snooze, says so with an Undo,
* and `unmute` lifts one; both reload the timeline, which leaves muted activities out on the server. */
export function useMutes() {
const client = useApiClient();
Expand All @@ -158,14 +162,14 @@ export function useMutes() {
onError: (e) => toast({ tone: "red", title: "Couldn’t save the mute", body: (e as Error).message }),
});
const mute = useCallback(
(target: { actor: string; name: string } | { kind: string; name: string }, until?: string) => {
(target: { actor: string; name: string } | { kind: string; name: string }, span?: Snooze) => {
mutate(
{ ...target, until, on: true },
{ ...target, until: span ? snoozeUntil(span) : undefined, on: true },
{
onSuccess: () =>
toast({
tone: "green",
title: until ? `Muted ${target.name} for a day` : `Muted ${target.name}`,
title: span ? `Muted ${target.name} for ${span}` : `Muted ${target.name}`,
body: "Its activities stay, for everyone else and for your automations; hidden=true shows them to you.",
action: { label: "Undo", onClick: () => mutate({ ...target, on: false }) },
}),
Expand Down
Loading
0