Add date mentions to the editor

Introduce a new "date" mention type alongside the existing user,
document and collection mentions. Typing @ with a natural language date
(e.g. "tomorrow", "next friday", "jan 2") surfaces a date suggestion,
parsed via chrono-node. Dates are stored as date-only ISO strings and
displayed with increasing granularity (Today / Tomorrow / January 2nd /
February 3rd, 2024), recomputed dynamically so relative labels stay
fresh. Clicking a date mention opens a Radix popover calendar to change
it.
This commit is contained in:
Claude
2026-06-07 18:43:22 +00:00
parent ac6b680cdb
commit 31e111e4d8
12 changed files with 463 additions and 5 deletions
+4
View File
@@ -13,6 +13,10 @@ ActiveCollectionSection.priority = 0.8;
export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
export const DateSection = ({ t }: ActionContext) => t("Date");
DateSection.priority = 1;
export const DocumentSection = ({ t }: ActionContext) => t("Document");
export const SearchResultsSection = ({ t }: ActionContext) =>
+39 -1
View File
@@ -2,6 +2,7 @@ import { isEmail } from "class-validator";
import { observer } from "mobx-react";
import { v4 as uuidv4 } from "uuid";
import {
CalendarIcon,
DocumentIcon,
PlusIcon,
NewDocumentIcon,
@@ -14,11 +15,18 @@ import { toast } from "sonner";
import Icon from "@shared/components/Icon";
import type { MenuItem } from "@shared/editor/types";
import { MentionType } from "@shared/types";
import {
dateToReadable,
dateToRelativeReadable,
toISODate,
} from "@shared/utils/date";
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
import { parseNaturalLanguageDate } from "@shared/utils/parseNaturalLanguageDate";
import { Avatar, AvatarSize, GroupAvatar } from "~/components/Avatar";
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
import Flex from "~/components/Flex";
import {
DateSection,
DocumentsSection,
UserSection,
CollectionsSection,
@@ -26,6 +34,7 @@ import {
} from "~/actions/sections";
import useRequest from "~/hooks/useRequest";
import useStores from "~/hooks/useStores";
import useUserLocale from "~/hooks/useUserLocale";
import { client } from "~/utils/ApiClient";
import type { Props as SuggestionsMenuProps } from "./SuggestionsMenu";
import SuggestionsMenu from "./SuggestionsMenu";
@@ -54,8 +63,34 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
const actorId = auth.currentUserId;
const location = useLocation();
const documentId = parseDocumentSlug(location.pathname);
const userLocale = useUserLocale();
const maxResultsInSection = search ? 25 : 5;
// Surface a date suggestion when the search query parses as a natural
// language date (e.g. "tomorrow", "next friday", "jan 2").
const parsedDate = search ? parseNaturalLanguageDate(search) : null;
const parsedISODate = parsedDate ? toISODate(parsedDate) : undefined;
const dateItems: MentionItem[] =
actorId && parsedISODate
? [
{
name: "mention",
icon: <CalendarIcon />,
title: dateToRelativeReadable(parsedISODate, t, userLocale),
subtitle: dateToReadable(parsedISODate, userLocale),
section: DateSection,
appendSpace: true,
attrs: {
id: uuidv4(),
type: MentionType.Date,
modelId: parsedISODate,
actorId,
label: dateToReadable(parsedISODate, userLocale),
},
} as MentionItem,
]
: [];
const { loading, request } = useRequest(
useCallback(async () => {
const res = await client.post("/suggestions.mention", {
@@ -87,7 +122,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
// Computed in the render body so MobX observer can track store access
// (e.g. searchSuppressed). Previously this lived inside a useEffect which
// runs outside the reactive context and triggered MobX warnings.
const items: MentionItem[] = actorId
const mentionItems: MentionItem[] = actorId
? users
.findByQuery(search, { maxResults: maxResultsInSection })
.map(
@@ -253,9 +288,12 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
])
: [];
const items: MentionItem[] = [...dateItems, ...mentionItems];
const handleSelect = useCallback(
async (item: MentionItem) => {
if (
item.attrs.type === MentionType.Date ||
item.attrs.type === MentionType.Document ||
item.attrs.type === MentionType.Collection
) {
+1
View File
@@ -115,6 +115,7 @@
"addressparser": "^1.0.1",
"async-sema": "^3.1.1",
"bull": "^4.16.5",
"chrono-node": "^2.9.1",
"class-validator": "^0.15.1",
"command-score": "^0.1.2",
"compressorjs": "^1.3.0",
+126 -1
View File
@@ -1,5 +1,7 @@
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { observer } from "mobx-react";
import {
CalendarIcon,
DocumentIcon,
EmailIcon,
CollectionIcon,
@@ -7,9 +9,18 @@ import {
} from "outline-icons";
import type { Node } from "prosemirror-model";
import * as React from "react";
import { DayPicker } from "react-day-picker";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import styled from "styled-components";
import styled, { useTheme } from "styled-components";
import { depths, s } from "../../styles";
import {
dateLocale,
dateToReadable,
dateToRelativeReadable,
parseISODate,
toISODate,
} from "../../utils/date";
import { Backticks } from "../../components/Backticks";
import Flex from "../../components/Flex";
import Icon from "../../components/Icon";
@@ -510,6 +521,93 @@ export const MentionPullRequest = observer((props: IssuePrProps) => {
);
});
type DateProps = ComponentProps & {
onChangeDate: (modelId: string, label: string) => void;
};
export const MentionDate = observer(function MentionDate_(props: DateProps) {
const { isSelected, isEditable, node, onChangeDate } = props;
const { t } = useTranslation();
const { auth } = useStores();
const theme = useTheme();
const [open, setOpen] = React.useState(false);
const { className, unfurl, ...attrs } = getAttributesFromNode(node);
const language = auth.user?.language;
const iso = typeof node.attrs.modelId === "string" ? node.attrs.modelId : "";
const display = dateToRelativeReadable(iso, t, language);
const selectedDate = parseISODate(iso) ?? undefined;
const styles = React.useMemo(
() =>
({
"--rdp-caption-font-size": "16px",
"--rdp-cell-size": "34px",
"--rdp-selected-text": theme.accentText,
"--rdp-accent-color": theme.accent,
"--rdp-accent-color-dark": theme.accent,
"--rdp-background-color": theme.listItemHoverBackground,
"--rdp-background-color-dark": theme.listItemHoverBackground,
}) as React.CSSProperties,
[theme]
);
const handleSelect = React.useCallback(
(date: Date) => {
setOpen(false);
const newIso = toISODate(date);
onChangeDate(newIso, dateToReadable(newIso, language));
},
[onChangeDate, language]
);
const trigger = (
<DateMention
{...attrs}
className={cn(className, {
"ProseMirror-selectednode": isSelected,
})}
$editable={isEditable}
>
<CalendarIcon size={18} />
{display}
</DateMention>
);
if (!isEditable) {
return trigger;
}
return (
<PopoverPrimitive.Root open={open} onOpenChange={setOpen}>
<PopoverPrimitive.Trigger
asChild
onMouseDown={(e) => e.stopPropagation()}
>
{trigger}
</PopoverPrimitive.Trigger>
<PopoverPrimitive.Portal>
<DatePopoverContent
sideOffset={4}
align="start"
aria-label={t("Choose a date")}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<DayPicker
required
mode="single"
selected={selectedDate}
defaultMonth={selectedDate}
onSelect={handleSelect}
style={styles}
locale={dateLocale(language)}
/>
</DatePopoverContent>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
);
});
const MentionLoading = ({ className }: { className: string }) => {
const { t } = useTranslation();
@@ -532,6 +630,33 @@ const MentionError = ({ className }: { className: string }) => {
);
};
const DateMention = styled.span<{ $editable: boolean }>`
cursor: ${(props) => (props.$editable ? "pointer" : "default")};
user-select: none;
`;
const DatePopoverContent = styled(PopoverPrimitive.Content)`
z-index: ${depths.modal};
background: ${s("menuBackground")};
box-shadow: ${s("menuShadow")};
border-radius: 6px;
outline: none;
padding: 6px 12px;
&[data-state="open"] {
animation: fadeIn 150ms ease;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
`;
const StyledWarningIcon = styled(WarningIcon)`
margin: 0 -2px;
`;
+36 -3
View File
@@ -21,6 +21,7 @@ import {
MentionIssue,
MentionProject,
MentionPullRequest,
MentionDate,
MentionURL,
MentionUser,
} from "../components/Mentions";
@@ -95,12 +96,21 @@ export default class Mention extends Node {
},
],
toDOM: (node) => [
node.attrs.type === MentionType.User ? "span" : "a",
node.attrs.type === MentionType.User ||
node.attrs.type === MentionType.Date
? "span"
: "a",
{
class: `${node.type.name} use-hover-preview`,
// Date mentions are self-contained and have nothing to unfurl, so
// they opt out of the hover preview behaviour.
class:
node.attrs.type === MentionType.Date
? node.type.name
: `${node.type.name} use-hover-preview`,
id: node.attrs.id,
href:
node.attrs.type === MentionType.User
node.attrs.type === MentionType.User ||
node.attrs.type === MentionType.Date
? undefined
: node.attrs.type === MentionType.Document
? `${env.URL}/doc/${node.attrs.modelId}`
@@ -162,6 +172,10 @@ export default class Mention extends Node {
onChangeUnfurl={this.handleChangeUnfurl(props)}
/>
);
case MentionType.Date:
return (
<MentionDate {...props} onChangeDate={this.handleChangeDate(props)} />
);
default:
return null;
}
@@ -341,6 +355,25 @@ export default class Mention extends Node {
};
}
handleChangeDate =
({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) =>
(modelId: string, label: string) => {
const { view } = this.editor;
const { tr } = view.state;
const pos = getPos();
if (node.attrs.modelId === modelId && node.attrs.label === label) {
return;
}
const transaction = tr.setNodeMarkup(pos, undefined, {
...node.attrs,
modelId,
label,
});
view.dispatch(transaction);
};
handleChangeUnfurl =
({ node, getPos }: { node: ProsemirrorNode; getPos: () => number }) =>
(unfurl: UnfurlResponse[keyof UnfurlResponse]) => {
+15
View File
@@ -98,6 +98,21 @@ describe("mention rule", () => {
});
});
describe("date format", () => {
it("should parse a date mention with an ISO date modelId", () => {
const result = md.parse(
"@[February 3rd, 2024](mention://a1b2c3d4-e5f6-7890-abcd-ef1234567890/date/2024-02-03)",
{}
);
const mentions = findMentionTokens(result);
expect(mentions).toHaveLength(1);
expect(mentions[0].type).toBe("date");
expect(mentions[0].modelId).toBe("2024-02-03");
expect(mentions[0].label).toBe("February 3rd, 2024");
});
});
describe("mixed content", () => {
it("should parse mention within text", () => {
const result = md.parse(
+1
View File
@@ -121,6 +121,7 @@ export enum MentionType {
PullRequest = "pull_request",
Project = "project",
URL = "url",
Date = "date",
}
export type PublicEnv = {
+62
View File
@@ -0,0 +1,62 @@
import {
dateToReadable,
dateToRelativeReadable,
parseISODate,
toISODate,
} from "./date";
describe("toISODate / parseISODate", () => {
it("round-trips a date through its ISO representation", () => {
const date = new Date(2024, 1, 3); // Feb 3, 2024
const iso = toISODate(date);
expect(iso).toBe("2024-02-03");
expect(parseISODate(iso)).toEqual(date);
});
it("returns null for an invalid ISO string", () => {
expect(parseISODate("not-a-date")).toBeNull();
});
});
describe("dateToReadable", () => {
it("formats an absolute, human-readable date", () => {
expect(dateToReadable("2024-02-03")).toBe("February 3rd, 2024");
});
it("returns the original string when invalid", () => {
expect(dateToReadable("nonsense")).toBe("nonsense");
});
});
describe("dateToRelativeReadable", () => {
const t = (key: string) => key;
it("returns Today for the current date", () => {
const iso = toISODate(new Date());
expect(dateToRelativeReadable(iso, t)).toBe("Today");
});
it("returns Tomorrow for the next day", () => {
const tomorrow = new Date();
tomorrow.setDate(tomorrow.getDate() + 1);
expect(dateToRelativeReadable(toISODate(tomorrow), t)).toBe("Tomorrow");
});
it("returns Yesterday for the previous day", () => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
expect(dateToRelativeReadable(toISODate(yesterday), t)).toBe("Yesterday");
});
it("omits the year within the current year", () => {
const date = new Date();
date.setMonth(date.getMonth() === 0 ? 6 : 0); // a different month, same year
date.setDate(15);
const result = dateToRelativeReadable(toISODate(date), t);
expect(result).not.toContain(`${date.getFullYear()}`);
});
it("includes the year for a date in a different year", () => {
expect(dateToRelativeReadable("2020-02-03", t)).toBe("February 3rd, 2020");
});
});
+86
View File
@@ -2,7 +2,13 @@
import type { Locale } from "date-fns";
import {
addSeconds,
format,
formatDistanceToNow,
isSameYear,
isToday,
isTomorrow,
isYesterday,
parseISO,
subDays,
subMonths,
subWeeks,
@@ -297,3 +303,83 @@ export function dateLocale(language: keyof typeof locales | undefined | null) {
}
export { locales };
/**
* Formats a Date into a date-only ISO string (yyyy-MM-dd) in the local
* timezone. Used as the stored value for date mentions.
*
* @param date The date to format.
* @returns the date-only ISO string.
*/
export function toISODate(date: Date): string {
return format(date, "yyyy-MM-dd");
}
/**
* Parses a date-only ISO string (yyyy-MM-dd) into a Date at local midnight.
*
* @param iso The date-only ISO string.
* @returns the parsed Date, or null when the string is not a valid date.
*/
export function parseISODate(iso: string): Date | null {
const date = parseISO(iso);
return isValid(date) ? date : null;
}
/**
* Formats a date mention's stored ISO value into an absolute, localized,
* human-readable label (e.g. "January 2nd, 2024"). Suitable for plaintext
* and markdown serialization where a stable, unambiguous value is required.
*
* @param iso The date-only ISO string.
* @param language The user's language preference.
* @returns the absolute human-readable date, or the original string when invalid.
*/
export function dateToReadable(
iso: string,
language?: keyof typeof locales | null
): string {
const date = parseISODate(iso);
if (!date) {
return iso;
}
return format(date, "MMMM do, yyyy", { locale: dateLocale(language) });
}
/**
* Formats a date mention's stored ISO value into a relative, localized,
* human-readable label with increasing granularity. Returns "Today",
* "Tomorrow" or "Yesterday" where applicable, "January 2nd" within the
* current year, and "February 3rd, 2024" otherwise.
*
* @param iso The date-only ISO string.
* @param t The translation function.
* @param language The user's language preference.
* @returns the relative human-readable date, or the original string when invalid.
*/
export function dateToRelativeReadable(
iso: string,
t: (key: string) => string,
language?: keyof typeof locales | null
): string {
const date = parseISODate(iso);
if (!date) {
return iso;
}
if (isToday(date)) {
return t("Today");
}
if (isTomorrow(date)) {
return t("Tomorrow");
}
if (isYesterday(date)) {
return t("Yesterday");
}
const locale = dateLocale(language);
if (isSameYear(date, new Date())) {
return format(date, "MMMM do", { locale });
}
return format(date, "MMMM do, yyyy", { locale });
}
@@ -0,0 +1,45 @@
import { parseNaturalLanguageDate } from "./parseNaturalLanguageDate";
describe("parseNaturalLanguageDate", () => {
const reference = new Date(2024, 0, 1); // Mon Jan 1, 2024
it("returns null for empty input", () => {
expect(parseNaturalLanguageDate("", reference)).toBeNull();
expect(parseNaturalLanguageDate(" ", reference)).toBeNull();
});
it("returns null for non-date input", () => {
expect(parseNaturalLanguageDate("hello world", reference)).toBeNull();
});
it("parses 'today'", () => {
const result = parseNaturalLanguageDate("today", reference);
expect(result).toEqual(new Date(2024, 0, 1));
});
it("parses 'tomorrow'", () => {
const result = parseNaturalLanguageDate("tomorrow", reference);
expect(result).toEqual(new Date(2024, 0, 2));
});
it("parses 'yesterday'", () => {
const result = parseNaturalLanguageDate("yesterday", reference);
expect(result).toEqual(new Date(2023, 11, 31));
});
it("parses 'in 3 days'", () => {
const result = parseNaturalLanguageDate("in 3 days", reference);
expect(result).toEqual(new Date(2024, 0, 4));
});
it("parses an explicit month and day", () => {
const result = parseNaturalLanguageDate("February 3", reference);
expect(result).toEqual(new Date(2024, 1, 3));
});
it("normalizes the time component to local midnight", () => {
const result = parseNaturalLanguageDate("tomorrow at 5pm", reference);
expect(result?.getHours()).toBe(0);
expect(result?.getMinutes()).toBe(0);
});
});
+40
View File
@@ -0,0 +1,40 @@
import * as chrono from "chrono-node";
/**
* Parse a natural language string such as "tomorrow", "next friday",
* "jan 2" or "in 3 days" into a calendar date.
*
* The time component is intentionally discarded as date mentions are
* day-granular; only the year, month and day of the matched date are
* returned.
*
* @param input the natural language string to parse.
* @param referenceDate the date relative to which terms like "tomorrow"
* are resolved, defaults to now.
* @returns the matched date with the time set to local midnight, or null
* when no date could be confidently parsed.
*/
export function parseNaturalLanguageDate(
input: string,
referenceDate: Date = new Date()
): Date | null {
const trimmed = input.trim();
if (!trimmed) {
return null;
}
const results = chrono.parse(trimmed, referenceDate, { forwardDate: true });
const result = results[0];
if (!result) {
return null;
}
// Only accept matches that span (roughly) the whole input so that
// unrelated text typed after "@" does not accidentally resolve to a date.
if (result.text.trim().length < trimmed.length) {
return null;
}
const date = result.start.date();
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
}
+8
View File
@@ -8682,6 +8682,13 @@ __metadata:
languageName: node
linkType: hard
"chrono-node@npm:^2.9.1":
version: 2.9.1
resolution: "chrono-node@npm:2.9.1"
checksum: 10c0/2408e4a404ea3e7e8226daae75cdbc4621bc66065d596574440d0f81a7c456c166ca212c9e1c329e653c716300289498794fece2102f9365bfd33bd1a4b24473
languageName: node
linkType: hard
"ci-info@npm:^3.7.0":
version: 3.9.0
resolution: "ci-info@npm:3.9.0"
@@ -15178,6 +15185,7 @@ __metadata:
babel-plugin-transform-typescript-metadata: "npm:^0.4.0"
browserslist-to-esbuild: "npm:^1.2.0"
bull: "npm:^4.16.5"
chrono-node: "npm:^2.9.1"
class-validator: "npm:^0.15.1"
command-score: "npm:^0.1.2"
compressorjs: "npm:^1.3.0"