8000 feat(timeline): mute for a day or by kind from the item's menu by adeelahmad · Pull Request #643 · 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
3 changes: 3 additions & 0 deletions changelog.d/timeline-mute-menu.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
The Mute button on a timeline item now has a small menu beside it: mute the source or person for a day (a snooze that
runs out by itself), or mute the item's kind, for good or for a day, without leaving the page. The Muted row shows
when a snooze ends.
2 changes: 1 addition & 1 deletion docs/requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,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` |
| 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` |
| 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` |
| 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` |
| Every entity's own page at `/wiki/<namespace>/<Name>` | [test_entities.py](https://github.com/adeelahmad/lense/blob/main/fastapi_backend/tests/api/test_entities.py) `test_wiki_addresses`; [wiki-page.test.tsx](https://github.com/adeelahmad/lense/blob/main/nextjs-frontend/__tests__/wiki-page.test.tsx); [journeys.mjs](https://github.com/adeelahmad/lense/blob/main/ci/clicks/journeys.mjs) `entity-find` |
Expand Down
9 changes: 5 additions & 4 deletions docs/timeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,8 +194,10 @@ still trigger automations and still show to everyone else, and come back the mom
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, and the **Muted** row above the list lifts one
with its ×. Muting a kind, or until a time, is through the API, MCP and the assistant for now.
- 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 ×.
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
yourself; `"on": false` lifts it. The answer, and `GET /api/v1/activities/mutes`, is your mutes now: `{actor, kind,
Expand Down Expand Up @@ -245,11 +247,10 @@ 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.
- Muting a kind, or for a day, from the page (a menu on Mute), and a muted actor's own line in the Filters.
- A muted actor's own line in the Filters, and a snooze of your own length from the page.
- A file changed in its source and read again is `asset.added` once more; linking the new activity to the
asset's earlier one as a `file.changed` Update, rather than a second Create, is to come.
- Saved filter views.
- A daily digest of unreviewed items, as an automation starter.
- Marks on activities whose rows are gone (an event log entry past `events.keep_days`, a deleted asset) stay until
the person is removed; a tidy could drop them. Reactions and comments on such activities likewise.
- Who gave each reaction, on hover; editing and resolving comments from the timeline (the API has both).
Expand Down
30 changes: 30 additions & 0 deletions nextjs-frontend/__tests__/timeline-ui.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -481,3 +481,33 @@ 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 () => {
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
fireEvent.click(within(rows[1]).getByRole("button", { name: "Show details" }));
fireEvent.keyDown(within(rows[1]).getByRole("button", { name: "More ways to mute Adeel" }), { key: "Enter" });
fireEvent.click(await screen.findByRole("menuitem", { name: "Mute Asset added items" }));
await waitFor(() =>
expect(m(Activities.muteActivities)).toHaveBeenCalledWith(
expect.objectContaining({ body: { kind: "asset.added", name: "Asset added", until: undefined, on: true } }),
),
);
await waitFor(() => expect(screen.getAllByTestId("timeline-row")).toHaveLength(1));
// a source for a day: until is tomorrow, and the notice says so
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" }));
await waitFor(() =>
expect(m(Activities.muteActivities)).toHaveBeenLastCalledWith(
expect.objectContaining({
body: expect.objectContaining({ actor: "lens:source/1", name: "Work Gmail", on: true }),
}),
),
);
const until = m(Activities.muteActivities).mock.lastCall![0].body.until as string;
expect(Date.parse(until) - Date.now()).toBeGreaterThan(23 * 3_600_000);
expect(await screen.findByText("Muted Work Gmail for a day")).toBeInTheDocument();
});
79 changes: 58 additions & 21 deletions nextjs-frontend/components/timeline/item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@ import {
ChevronRight,
Circle,
CircleCheck,
Clock,
EyeOff,
FileText,
VolumeX,
Mail,
MessagesSquare,
NotebookPen,
RadioTower,
Star,
VolumeX,
Workflow,
type LucideIcon,
} from "lucide-react";
Expand All @@ -38,7 +39,8 @@ import {
import { SocialSummary } from "@/components/timeline/social";
import { sumReactions } from "@/components/timeline/social-model";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Button, IconButton } from "@/components/ui/button";
import { Menu, MenuContent, MenuItem, MenuTrigger } from "@/components/ui/menu";
import { DateTime } from "@/components/ui/states";
import { cn } from "@/lib/utils";

Expand Down Expand Up @@ -126,15 +128,18 @@ export function ActivityDetail({
social,
onHide,
onMute,
kindLabel,
}: {
a: Activity;
/** What this activity caused (automation runs, approvals), with its own heading (timeline/caused.tsx). */
caused?: ReactNode;
/** Its reactions and comments (timeline/social.tsx). */
social?: ReactNode;
onHide?: () => void;
/** Mute its actor: everything from this source or person leaves your timeline (docs/timeline.md, Muting). */
onMute?: () => void;
/** Mute its actor (or, from the menu, its kind, for good or a day): docs/timeline.md, Muting. */
onMute?: (opts?: MuteOpts) => void;
/** The item's kind label, for "Mute Email received items". */
kindLabel?: string;
}) {
const fields = objectFields(a);
const href = objectHref(a);
Expand Down Expand Up @@ -172,23 +177,51 @@ export function ActivityDetail({
Hide
</Button>
)}
{onMute && <MuteButton name={a.actor.name} />}
{onMute && <MuteButton name={a.actor.name} kind={kindLabel} />}
</div>
</div>
);
}

function MuteButton({ name, onMute }: { name: string; onMute: () => void }) {
/** 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 };

/** Mute the item's actor with one click, and the other ways (for a day, its kind) behind a small menu beside it. */
function MuteButton({ name, kind, onMute }: { name: string; kind?: string; onMute: (opts?: MuteOpts) => void }) {
return (
<Button
size="xs"
variant="ghost"
icon={<VolumeX />}
>
title={`Leave everything from ${name} out of your timeline; nothing is deleted, and Undo or the Muted list brings it back`}
>
Mute {name}
</Button>
<span className="inline-flex items-center">
<Button
size="xs"
variant="ghost"
icon={<VolumeX />}
=> onMute()}
title={`Leave everything from ${name} out of your timeline; nothing is deleted, and Undo or the Muted list brings it back`}
>
Mute {name}
</Button>
<Menu>
<MenuTrigger asChild>
<IconButton label={`More ways to mute ${name}`} size={24}>
<ChevronDown />
</IconButton>
</MenuTrigger>
<MenuContent align="start">
<MenuItem icon={<Clock />} => onMute({ day: true })}>
Mute {name} for a day
</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>
</>
)}
</MenuContent>
</Menu>
</span>
);
}

Expand All @@ -202,8 +235,8 @@ type RowProps = {
markOf: (id: string) => Mark;
onMark: (ids: string[], flags: Partial<Mark>) => void;
onHide?: (ids: string[]) => void;
/** Mute an item's actor (docs/timeline.md, Muting). */
onMute?: (a: Activity) => void;
/** Mute an item's actor, or its kind, for good or a day (docs/timeline.md, Muting). */
onMute?: (a: Activity, opts?: MuteOpts) => void;
open: boolean;
onToggle: () => void;
focused?: boolean;
Expand Down Expand Up @@ -359,7 +392,8 @@ export const TimelineRow = forwardRef<HTMLLIElement, RowProps>(function Timeline
mark={markOf(a.id)}
=> onMark([a.id], flags)}
? () => onHide([a.id]) : undefined}
? () => onMute(a) : undefined}
? (opts) => onMute(a, opts) : undefined}
kindLabel={labels[a.kind]}
caused={caused?.(a)}
social={social?.(a)}
/>
Expand All @@ -371,7 +405,8 @@ export const TimelineRow = forwardRef<HTMLLIElement, RowProps>(function Timeline
caused={caused?.(first)}
social={social?.(first)}
? () => onHide(ids) : undefined}
? () => onMute(first) : undefined}
? (opts) => onMute(first, opts) : undefined}
kindLabel={labels[first.kind]}
/>
))}
</li>
Expand All @@ -384,14 +419,16 @@ function SubItem({
onMark,
onHide,
onMute,
kindLabel,
caused,
social,
}: {
a: Activity;
mark: Mark;
onMark: (flags: Partial<Mark>) => void;
onHide?: () => void;
onMute?: () => void;
onMute?: (opts?: MuteOpts) => void;
kindLabel?: string;
caused?: ReactNode;
social?: ReactNode;
}) {
Expand Down Expand Up @@ -422,7 +459,7 @@ function SubItem({
Hide
</Button>
)}
{onMute && <MuteButton name={a.actor.name} />}
{onMute && <MuteButton name={a.actor.name} kind={kindLabel} />}
</div>
</li>
);
Expand Down
20 changes: 14 additions & 6 deletions nextjs-frontend/components/timeline/timeline-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { ActivityMark, Reaction } from "@/app/openapi-client/types.gen";
import { askAbout } from "@/components/page-chat/page-chat-model";
import { Caused } from "@/components/timeline/caused";
import { FiltersBar, type Actor } from "@/components/timeline/filters";
import { TimelineRow, groupMark } from "@/components/timeline/item";
import { TimelineRow, groupMark, type MuteOpts } from "@/components/timeline/item";
import {
askQuote,
filterCount,
Expand All @@ -30,7 +30,7 @@ import {
} from "@/components/timeline/model";
import { Social } from "@/components/timeline/social";
import { useCommentCounts, useReactions } from "@/components/timeline/use-social";
import { useKinds, useMarks, useMutes, useNewItems, useTimeline } from "@/components/timeline/use-timeline";
import { aDay, 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 @@ -158,9 +158,10 @@ export function TimelinePage() {
const counts = useCommentCounts(serverComments);
const mutes = useMutes();
const mutedActors = useMemo(() => new Set(mutes.mutes.map((m) => m.actor).filter(Boolean)), [mutes.mutes]);
const mutedKinds = useMemo(() => new Set(mutes.mutes.map((m) => m.kind).filter(Boolean)), [mutes.mutes]);
const shown = useMemo(
() => all.filter((a) => !marks.of(a.id).hidden && !mutedActors.has(a.actor.id)),
[all, marks, mutedActors],
() => all.filter((a) => !marks.of(a.id).hidden && !mutedActors.has(a.actor.id) && !mutedKinds.has(a.kind)),
[all, marks, mutedActors, mutedKinds],
);
const groups = useMemo(() => group(shown), [shown]);
const labels = useMemo(() => Object.fromEntries((kinds.data ?? []).map((k) => [k.name, k.label])), [kinds.data]);
Expand Down Expand Up @@ -213,7 +214,14 @@ export function TimelinePage() {
),
[marks],
);
const muteActor = useCallback((a: Activity) => mutes.mute({ actor: a.actor.id, name: a.actor.name }), [mutes]);
const muteFrom = useCallback(
(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,
),
[mutes, labels],
);
const markAllShown = () => {
const ids = shown.filter((a) => !marks.of(a.id).reviewed).map((a) => a.id);
marks.set(ids, { reviewed: true }, { title: `Marked ${ids.length} reviewed`, undo: true });
Expand Down Expand Up @@ -378,7 +386,7 @@ export function TimelinePage() {
markOf={marks.of}
>
>
class="x x-first x-last">muteActor}
class="x x-first x-last">muteFrom}
open={open.has(g.key)}
=> toggle(g.key)}
focused={i === active}
Expand Down
Loading
Loading
0