@protobase/ui 0.53.4 → 0.53.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/dist/app/comments/comment-body.js +5 -8
  2. package/dist/app/comments/comment-composer.js +29 -114
  3. package/dist/app/comments/messages.js +0 -4
  4. package/dist/chat/chat-day.d.ts +6 -0
  5. package/dist/chat/chat-day.js +17 -0
  6. package/dist/chat/chat-divider.d.ts +15 -0
  7. package/dist/chat/chat-divider.js +25 -0
  8. package/dist/chat/chat-message.d.ts +26 -4
  9. package/dist/chat/chat-message.js +65 -5
  10. package/dist/chat/chat-thread.d.ts +13 -3
  11. package/dist/chat/chat-thread.js +85 -16
  12. package/dist/chat/composer.d.ts +12 -4
  13. package/dist/chat/composer.js +30 -15
  14. package/dist/chat/index.d.ts +4 -2
  15. package/dist/chat/messages.js +10 -2
  16. package/dist/chat/thread-scroll.js +15 -0
  17. package/dist/data/query-keys.js +0 -7
  18. package/dist/index.d.ts +10 -3
  19. package/dist/index.js +6 -1
  20. package/dist/mentions/index.d.ts +5 -0
  21. package/dist/mentions/mention-box.d.ts +27 -0
  22. package/dist/mentions/mention-box.js +125 -0
  23. package/dist/mentions/mention-text.d.ts +9 -0
  24. package/dist/{app/comments → mentions}/mention-text.js +1 -1
  25. package/dist/mentions/mentioned-text.d.ts +9 -0
  26. package/dist/mentions/mentioned-text.js +13 -0
  27. package/dist/mentions/messages.js +16 -0
  28. package/dist/mentions/people-list.js +26 -0
  29. package/dist/mentions/use-people-search.d.ts +5 -0
  30. package/dist/mentions/use-people-search.js +26 -0
  31. package/dist/package.js +1 -1
  32. package/dist/shell/assets/{app-KfucDRFg.js → app-lIH0RyO8.js} +8 -8
  33. package/dist/shell/assets/{index-DITOhzRs.js → index-BUHPj0Eg.js} +2 -2
  34. package/dist/shell/assets/{index-DFC2GwM_.css → index-BX1N2n2W.css} +1 -1
  35. package/dist/shell/assets/{records-map-DLdvIE4o.js → records-map-_qjIukY1.js} +2 -2
  36. package/dist/shell/assets/shared-protobase-ui-MwC2WreJ.js +2 -0
  37. package/dist/shell/index.html +4 -4
  38. package/dist/shell/protobase.shell.json +1 -1
  39. package/dist/shell/protobase.sw.js +2 -2
  40. package/dist/shell-source-maps/assets/app-lIH0RyO8.js.map +1 -0
  41. package/dist/shell-source-maps/assets/{index-DITOhzRs.js.map → index-BUHPj0Eg.js.map} +1 -1
  42. package/dist/shell-source-maps/assets/{records-map-DLdvIE4o.js.map → records-map-_qjIukY1.js.map} +1 -1
  43. package/dist/shell-source-maps/assets/{shared-protobase-ui-Ss_2gqqM.js.map → shared-protobase-ui-MwC2WreJ.js.map} +1 -1
  44. package/package.json +4 -4
  45. package/dist/app/comments/use-mentionable.js +0 -18
  46. package/dist/shell/assets/shared-protobase-ui-Ss_2gqqM.js +0 -2
  47. package/dist/shell-source-maps/assets/app-KfucDRFg.js.map +0 -1
@@ -1,13 +1,10 @@
1
- import { bodyParts } from "./mention-text.js";
2
- import { jsx, jsxs } from "react/jsx-runtime";
1
+ import { MentionedText } from "../../mentions/mentioned-text.js";
2
+ import { jsx } from "react/jsx-runtime";
3
3
  //#region src/app/comments/comment-body.tsx
4
4
  /** A comment's text as written, line breaks kept, each `@{name}` of someone it mentions picked out. */
5
- const CommentBody = ({ comment }) => /* @__PURE__ */ jsx("p", {
6
- className: "whitespace-pre-wrap break-words",
7
- children: bodyParts(comment.body, comment.mentions).map((part, index) => "mention" in part ? /* @__PURE__ */ jsxs("span", {
8
- className: "font-medium text-primary",
9
- children: ["@", part.mention]
10
- }, index) : part.text)
5
+ const CommentBody = ({ comment }) => /* @__PURE__ */ jsx(MentionedText, {
6
+ text: comment.body,
7
+ mentions: comment.mentions
11
8
  });
12
9
  //#endregion
13
10
  export { CommentBody };
@@ -1,70 +1,37 @@
1
- import { cn } from "../../lib/cn.js";
1
+ import { useClient } from "../../data/api-provider.js";
2
2
  import { Button } from "../../primitives/button.js";
3
3
  import { useToast } from "../../toasts/toast-provider.js";
4
4
  import { refusalReason } from "../displays/board-columns.js";
5
5
  import { useNavigationGuard } from "../router.js";
6
- import { Textarea } from "../../primitives/textarea.js";
7
- import { menuKeyTarget } from "../../primitives/menu-keys.js";
8
- import { activeMention, insertMention, mentionedIds } from "./mention-text.js";
9
- import { useMentionable } from "./use-mentionable.js";
6
+ import { mentionedIds } from "../../mentions/mention-text.js";
7
+ import { MentionBox, emptyDraft } from "../../mentions/mention-box.js";
10
8
  import { usePostComment } from "./use-post-comment.js";
11
9
  import { text } from "./messages.js";
12
- import { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
10
+ import { useCallback, useState } from "react";
13
11
  import { jsx, jsxs } from "react/jsx-runtime";
14
12
  //#region src/app/comments/comment-composer.tsx
15
- /** The most people the list offers; few enough that it never needs a scrollbar of its own. */
16
- const shownPeople = 8;
17
13
  /**
18
- * The box a comment is written in. Typing `@` lists the people who may read the record by the name typed after it:
19
- * the arrows move, Enter or Tab picks, Escape closes. ⌘/Ctrl+Enter or the button posts.
14
+ * The box a comment is written in. Typing `@` lists the people who may read the record by the name typed after it
15
+ * (see `MentionBox`). ⌘/Ctrl+Enter or the button posts.
20
16
  */
21
17
  const CommentComposer = ({ resource, recordKey }) => {
22
- const [draft, setDraft] = useState("");
23
- const [caret, setCaret] = useState(0);
24
- const [focused, setFocused] = useState(false);
25
- const [picked, setPicked] = useState([]);
26
- const [active, setActive] = useState(0);
27
- const [closed, setClosed] = useState();
28
- const box = useRef(null);
29
- const nextCaret = useRef(void 0);
30
- const listId = useId();
18
+ const [draft, setDraft] = useState(emptyDraft);
19
+ const client = useClient();
31
20
  const toast = useToast();
32
21
  const post = usePostComment(resource, recordKey);
33
- useNavigationGuard(draft.trim() !== "" || post.isPending, "input-lost");
34
- const mention = activeMention(draft, caret);
35
- const open = focused && mention !== void 0 && mention.start !== closed;
36
- const people = useMentionable(resource, recordKey, open ? mention.query : void 0);
37
- const options = open ? (people.data ?? []).slice(0, shownPeople) : [];
38
- const listed = open && people.data !== void 0;
39
- const optionId = (index) => `${listId}-${index}`;
40
- useEffect(() => setActive(0), [mention?.query]);
41
- const mentionStart = mention?.start;
42
- useEffect(() => {
43
- if (mentionStart === void 0) setClosed(void 0);
44
- }, [mentionStart]);
45
- useLayoutEffect(() => {
46
- if (nextCaret.current === void 0 || !box.current) return;
47
- box.current.setSelectionRange(nextCaret.current, nextCaret.current);
48
- setCaret(nextCaret.current);
49
- nextCaret.current = void 0;
50
- }, [draft]);
51
- const pick = (user) => {
52
- if (!mention) return;
53
- const next = insertMention(draft, mention, user.name);
54
- nextCaret.current = next.caret;
55
- setDraft(next.text);
56
- setPicked((current) => [...current, user]);
57
- };
22
+ const searchPeople = useCallback((query) => client.mentionable(resource, recordKey, query), [
23
+ client,
24
+ resource,
25
+ recordKey
26
+ ]);
27
+ useNavigationGuard(draft.text.trim() !== "" || post.isPending, "input-lost");
58
28
  const submit = () => {
59
- if (draft.trim() === "" || post.isPending) return;
29
+ if (draft.text.trim() === "" || post.isPending) return;
60
30
  post.mutate({
61
- body: draft,
62
- mentions: mentionedIds(draft, picked)
31
+ body: draft.text,
32
+ mentions: mentionedIds(draft.text, draft.picked)
63
33
  }, {
64
- onSuccess: () => {
65
- setDraft("");
66
- setPicked([]);
67
- },
34
+ onSuccess: () => setDraft(emptyDraft),
68
35
  onError: (error) => toast.show({
69
36
  state: "error",
70
37
  title: text("couldNotPost"),
@@ -73,75 +40,23 @@ const CommentComposer = ({ resource, recordKey }) => {
73
40
  });
74
41
  };
75
42
  const onKeyDown = (event) => {
76
- if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) {
77
- event.preventDefault();
78
- submit();
79
- return;
80
- }
81
- if (!open) return;
82
- const target = menuKeyTarget(event.key, active, options.length);
83
- if (target !== void 0 && (event.key === "ArrowDown" || event.key === "ArrowUp")) {
84
- event.preventDefault();
85
- setActive(target);
86
- return;
87
- }
88
- if ((event.key === "Enter" || event.key === "Tab") && options[active]) {
89
- event.preventDefault();
90
- pick(options[active]);
91
- return;
92
- }
93
- if (event.key === "Escape") {
94
- event.preventDefault();
95
- event.stopPropagation();
96
- setClosed(mention.start);
97
- }
43
+ if (event.key !== "Enter" || !(event.metaKey || event.ctrlKey)) return;
44
+ event.preventDefault();
45
+ submit();
98
46
  };
99
47
  return /* @__PURE__ */ jsxs("div", {
100
48
  className: "flex flex-col gap-2",
101
- children: [/* @__PURE__ */ jsxs("div", {
102
- className: "relative",
103
- children: [/* @__PURE__ */ jsx(Textarea, {
104
- ref: box,
105
- role: "combobox",
106
- "aria-label": text("comment"),
107
- "aria-multiline": true,
108
- "aria-autocomplete": "list",
109
- "aria-expanded": listed,
110
- "aria-controls": listId,
111
- "aria-activedescendant": listed && options[active] ? optionId(active) : void 0,
112
- placeholder: text("placeholder"),
113
- value: draft,
114
- onChange: (event) => {
115
- setDraft(event.target.value);
116
- setCaret(event.target.selectionStart);
117
- },
118
- onSelect: (event) => setCaret(event.currentTarget.selectionStart),
119
- onFocus: () => setFocused(true),
120
- onBlur: () => setFocused(false),
121
- onKeyDown
122
- }), listed && /* @__PURE__ */ jsxs("ul", {
123
- id: listId,
124
- role: "listbox",
125
- "aria-label": text("peopleToMention"),
126
- className: "absolute z-30 mt-1 w-full max-w-sm rounded-md border bg-background py-1 shadow-pop",
127
- onMouseDown: (event) => event.preventDefault(),
128
- children: [options.map((user, index) => /* @__PURE__ */ jsx("li", {
129
- id: optionId(index),
130
- role: "option",
131
- "aria-selected": index === active,
132
- className: cn("cursor-pointer truncate px-3 py-1.5", index === active && "bg-muted"),
133
- onMouseMove: () => index !== active && setActive(index),
134
- onClick: () => pick(user),
135
- children: user.name
136
- }, user.id)), options.length === 0 && /* @__PURE__ */ jsx("li", {
137
- className: "px-3 py-1.5 text-muted-foreground",
138
- children: text("noOneFound")
139
- })]
140
- })]
49
+ children: [/* @__PURE__ */ jsx(MentionBox, {
50
+ "aria-label": text("comment"),
51
+ placeholder: text("placeholder"),
52
+ value: draft,
53
+ onChange: setDraft,
54
+ searchPeople,
55
+ onKeyDown
141
56
  }), /* @__PURE__ */ jsx("div", { children: /* @__PURE__ */ jsx(Button, {
142
57
  variant: "primary",
143
58
  size: "sm",
144
- disabled: draft.trim() === "",
59
+ disabled: draft.text.trim() === "",
145
60
  loading: post.isPending,
146
61
  onClick: submit,
147
62
  children: text("comment")
@@ -5,8 +5,6 @@ const text = defineMessages({
5
5
  couldNotPost: "Could not post the comment",
6
6
  comment: "Comment",
7
7
  placeholder: "Write a comment, @ to mention someone",
8
- peopleToMention: "People to mention",
9
- noOneFound: "No one found",
10
8
  couldNotLoad: "Could not load the comments",
11
9
  showEarlier: "Show earlier comments",
12
10
  noComments: "No comments yet."
@@ -15,8 +13,6 @@ const text = defineMessages({
15
13
  couldNotPost: "Kon de opmerking niet plaatsen",
16
14
  comment: "Opmerking",
17
15
  placeholder: "Schrijf een opmerking, @ om iemand te noemen",
18
- peopleToMention: "Mensen om te noemen",
19
- noOneFound: "Niemand gevonden",
20
16
  couldNotLoad: "Kon de opmerkingen niet laden",
21
17
  showEarlier: "Eerdere opmerkingen tonen",
22
18
  noComments: "Nog geen opmerkingen."
@@ -0,0 +1,6 @@
1
+ //#region src/chat/chat-day.d.ts
2
+ /** Whether two ISO timestamps fall on the same day in `zone`, the viewer's unless given: a day divider goes between two messages that do not. */
3
+ export declare const sameChatDay: (a: string, b: string, zone?: string) => boolean;
4
+ /** The label of the divider above a day's first message, sent at `time`: "Today", "Yesterday" or "Mon, 26 Oct 2026", as the viewer's calendar in `zone` has it. */
5
+ export declare const chatDayLabel: (time: string, now?: number, zone?: string) => string;
6
+ //#endregion
@@ -0,0 +1,17 @@
1
+ import { dayIn, viewerZone } from "../format/zone.js";
2
+ import { formatWeekdayDate } from "../format/date.js";
3
+ import { text } from "./messages.js";
4
+ //#region src/chat/chat-day.ts
5
+ const dayBefore = (day) => (/* @__PURE__ */ new Date(Date.parse(`${day}T00:00:00Z`) - 864e5)).toISOString().slice(0, 10);
6
+ /** Whether two ISO timestamps fall on the same day in `zone`, the viewer's unless given: a day divider goes between two messages that do not. */
7
+ const sameChatDay = (a, b, zone = viewerZone()) => dayIn(Date.parse(a), zone) === dayIn(Date.parse(b), zone);
8
+ /** The label of the divider above a day's first message, sent at `time`: "Today", "Yesterday" or "Mon, 26 Oct 2026", as the viewer's calendar in `zone` has it. */
9
+ const chatDayLabel = (time, now = Date.now(), zone = viewerZone()) => {
10
+ const day = dayIn(Date.parse(time), zone);
11
+ const today = dayIn(now, zone);
12
+ if (day === today) return text("today");
13
+ if (day === dayBefore(today)) return text("yesterday");
14
+ return formatWeekdayDate(Date.parse(`${day}T00:00:00Z`));
15
+ };
16
+ //#endregion
17
+ export { chatDayLabel, sameChatDay };
@@ -0,0 +1,15 @@
1
+ //#region src/chat/chat-divider.d.ts
2
+ export type ChatDividerProps =
3
+ /** A muted line between the messages of two days, labelled with the later one, such as by `chatDayLabel`. */
4
+ {
5
+ tone?: 'day';
6
+ label: string;
7
+ } |
8
+ /** A line above the first message the reader has not seen, "New messages" unless labelled otherwise. */
9
+ {
10
+ tone: 'new';
11
+ label?: string;
12
+ };
13
+ /** A line across a chat with a label in its middle, between the messages of two days or above the new ones. */
14
+ export declare const ChatDivider: ({ tone, label }: ChatDividerProps) => import("react").JSX.Element;
15
+ //#endregion
@@ -0,0 +1,25 @@
1
+ import { cn } from "../lib/cn.js";
2
+ import { text } from "./messages.js";
3
+ import { jsx, jsxs } from "react/jsx-runtime";
4
+ //#region src/chat/chat-divider.tsx
5
+ /** A line across a chat with a label in its middle, between the messages of two days or above the new ones. */
6
+ const ChatDivider = ({ tone = "day", label }) => {
7
+ const shown = label ?? text("newMessages");
8
+ const line = cn("h-px flex-1", tone === "new" ? "bg-danger" : "bg-border");
9
+ return /* @__PURE__ */ jsxs("div", {
10
+ role: "separator",
11
+ "aria-label": shown,
12
+ "data-tone": tone,
13
+ className: "flex items-center gap-3 text-xs font-medium",
14
+ children: [
15
+ /* @__PURE__ */ jsx("span", { className: line }),
16
+ /* @__PURE__ */ jsx("span", {
17
+ className: tone === "new" ? "text-danger" : "text-muted-foreground",
18
+ children: shown
19
+ }),
20
+ /* @__PURE__ */ jsx("span", { className: line })
21
+ ]
22
+ });
23
+ };
24
+ //#endregion
25
+ export { ChatDivider };
@@ -1,8 +1,30 @@
1
1
  import { ReactNode } from "react";
2
2
  //#region src/chat/chat-message.d.ts
3
- /** One message of a chat: the user's as a bubble on the right, the other side's as plain content, cards included. */
4
- export declare const ChatMessage: ({ from, children }: {
5
- from: "user" | "assistant";
3
+ /** Who wrote a message of a chat between people: `id` gives their avatar its colour, `name` its initials. */
4
+ export type ChatAuthor = {
5
+ id: string;
6
+ name?: string;
7
+ };
8
+ type Common = {
6
9
  children: ReactNode;
7
- }) => import("react").JSX.Element;
10
+ /** A small line under the message, such as whether it was read. */
11
+ footer?: ReactNode;
12
+ };
13
+ export type ChatMessageProps = Common & ({
14
+ /** An assistant chat's side: the user's message as a bubble on the right, the other side's as plain content. */
15
+ from: 'user' | 'assistant';
16
+ author?: never;
17
+ } | {
18
+ /** A chat between people: the message on the left under its author's name and time, beside their avatar. */
19
+ author: ChatAuthor;
20
+ from?: never;
21
+ /** When it was sent, as an ISO timestamp, shown as the time of day. */
22
+ time?: string;
23
+ /** Drawn instead of the author's initials, such as their photo. */
24
+ avatar?: ReactNode;
25
+ /** It follows a message by the same author, so it leaves out their avatar, name and time. */
26
+ continued?: boolean;
27
+ });
28
+ /** One message of a chat, cards included: an assistant chat's user or assistant side, or a person's message with `author`. */
29
+ export declare const ChatMessage: (props: ChatMessageProps) => import("react").JSX.Element;
8
30
  //#endregion
@@ -1,11 +1,71 @@
1
1
  import { cn } from "../lib/cn.js";
2
- import { jsx } from "react/jsx-runtime";
2
+ import { formatDateTime, formatTime } from "../format/date.js";
3
+ import { Avatar } from "../primitives/avatar.js";
4
+ import { hueOf, initialsOf } from "../auth/user-to-shell.js";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
3
6
  //#region src/chat/chat-message.tsx
4
- /** One message of a chat: the user's as a bubble on the right, the other side's as plain content, cards included. */
5
- const ChatMessage = ({ from, children }) => /* @__PURE__ */ jsx("div", {
6
- "data-from": from,
7
- className: cn("flex min-w-0 flex-col gap-2 whitespace-pre-wrap break-words", from === "user" ? "ml-8 self-end rounded-lg rounded-br-sm bg-primary px-3 py-2 text-primary-foreground shadow-sm" : "mr-4 text-foreground"),
7
+ const Footer = ({ children, className }) => /* @__PURE__ */ jsx("p", {
8
+ className: cn("text-xs text-muted-foreground", className),
8
9
  children
9
10
  });
11
+ const Sent = ({ time }) => {
12
+ const at = Date.parse(time);
13
+ return /* @__PURE__ */ jsx("time", {
14
+ dateTime: time,
15
+ title: formatDateTime(at),
16
+ className: "text-xs text-muted-foreground",
17
+ children: formatTime(at)
18
+ });
19
+ };
20
+ /** One message of a chat, cards included: an assistant chat's user or assistant side, or a person's message with `author`. */
21
+ const ChatMessage = (props) => {
22
+ const { children, footer } = props;
23
+ if (props.author) {
24
+ const { author, time, avatar, continued } = props;
25
+ const name = author.name ?? author.id;
26
+ return /* @__PURE__ */ jsxs("div", {
27
+ "data-from": "person",
28
+ className: cn("flex min-w-0 gap-3", continued && "-mt-3"),
29
+ children: [/* @__PURE__ */ jsx("div", {
30
+ className: "w-8 shrink-0",
31
+ children: !continued && (avatar ?? /* @__PURE__ */ jsx(Avatar, {
32
+ initials: initialsOf(name) || "?",
33
+ hue: hueOf(author.id),
34
+ size: "md",
35
+ className: "ring-0"
36
+ }))
37
+ }), /* @__PURE__ */ jsxs("div", {
38
+ className: "flex min-w-0 flex-1 flex-col gap-0.5",
39
+ children: [
40
+ !continued && /* @__PURE__ */ jsxs("p", {
41
+ className: "flex items-baseline gap-2",
42
+ children: [/* @__PURE__ */ jsx("span", {
43
+ className: "font-medium text-foreground",
44
+ children: name
45
+ }), time && /* @__PURE__ */ jsx(Sent, { time })]
46
+ }),
47
+ /* @__PURE__ */ jsx("div", {
48
+ className: "flex min-w-0 flex-col gap-2 whitespace-pre-wrap break-words text-foreground",
49
+ children
50
+ }),
51
+ footer && /* @__PURE__ */ jsx(Footer, { children: footer })
52
+ ]
53
+ })]
54
+ });
55
+ }
56
+ const bubble = /* @__PURE__ */ jsx("div", {
57
+ "data-from": props.from,
58
+ className: cn("flex min-w-0 flex-col gap-2 whitespace-pre-wrap break-words", props.from === "user" ? "ml-8 self-end rounded-lg rounded-br-sm bg-primary px-3 py-2 text-primary-foreground shadow-sm" : "mr-4 text-foreground"),
59
+ children
60
+ });
61
+ if (!footer) return bubble;
62
+ return /* @__PURE__ */ jsxs("div", {
63
+ className: cn("flex min-w-0 flex-col gap-1", props.from === "user" && "items-end"),
64
+ children: [bubble, /* @__PURE__ */ jsx(Footer, {
65
+ className: props.from === "user" ? "text-right" : void 0,
66
+ children: footer
67
+ })]
68
+ });
69
+ };
10
70
  //#endregion
11
71
  export { ChatMessage };
@@ -4,9 +4,19 @@ export type ChatThreadProps = {
4
4
  children: ReactNode;
5
5
  /** Shown centred while the chat is empty. */
6
6
  empty?: ReactNode;
7
- /** Changes whenever the chat grows, which scrolls it to the end. */
7
+ /** The number of messages, which changes whenever the chat grows. */
8
8
  length: number;
9
+ /** Loads the messages before the first, offered as a button at the top while given. */
10
+ onLoadEarlier?: () => void;
11
+ /** The earlier messages are on their way. */
12
+ loadingEarlier?: boolean;
13
+ /** Changes when the reader sends a message, such as a count of their sends: each change goes to the end wherever they were, and the message that arrives next follows. */
14
+ followKey?: string | number;
9
15
  };
10
- /** The scrolling list of a chat's messages, kept at the newest one as messages arrive. */
11
- export declare const ChatThread: ({ children, empty, length }: ChatThreadProps) => import("react").JSX.Element;
16
+ /**
17
+ * The scrolling list of a chat's messages. A reader at the end follows new messages; one who scrolled back stays where
18
+ * they are until `followKey` changes, and earlier messages loaded above keep them on the message they were reading. It
19
+ * scrolls inside itself, not with the page, since a chat keeps its composer in view beneath it.
20
+ */
21
+ export declare const ChatThread: ({ children, empty, length, onLoadEarlier, loadingEarlier, followKey }: ChatThreadProps) => import("react").JSX.Element;
12
22
  //#endregion
@@ -1,22 +1,91 @@
1
- import { useEffect, useRef } from "react";
2
- import { jsx } from "react/jsx-runtime";
1
+ import { cn } from "../lib/cn.js";
2
+ import { Button } from "../primitives/button.js";
3
+ import { text } from "./messages.js";
4
+ import { isAtEnd, scrollAfterChange } from "./thread-scroll.js";
5
+ import { useEffect, useLayoutEffect, useRef } from "react";
6
+ import { jsx, jsxs } from "react/jsx-runtime";
3
7
  //#region src/chat/chat-thread.tsx
4
- /** The scrolling list of a chat's messages, kept at the newest one as messages arrive. */
5
- const ChatThread = ({ children, empty, length }) => {
8
+ /** The message the reader is reading: the first whose bottom shows. */
9
+ const readPlace = (box, list, followKey) => {
10
+ const top = box.getBoundingClientRect().top;
11
+ const element = [...list.children].find((child) => child.getBoundingClientRect().bottom > top);
12
+ const anchor = element && { anchor: {
13
+ element,
14
+ top: element.getBoundingClientRect().top - top
15
+ } };
16
+ return {
17
+ atEnd: isAtEnd(box),
18
+ first: list.firstElementChild ?? void 0,
19
+ followKey,
20
+ ...anchor
21
+ };
22
+ };
23
+ /**
24
+ * The scrolling list of a chat's messages. A reader at the end follows new messages; one who scrolled back stays where
25
+ * they are until `followKey` changes, and earlier messages loaded above keep them on the message they were reading. It
26
+ * scrolls inside itself, not with the page, since a chat keeps its composer in view beneath it.
27
+ */
28
+ const ChatThread = ({ children, empty, length, onLoadEarlier, loadingEarlier, followKey }) => {
29
+ const box = useRef(null);
6
30
  const list = useRef(null);
31
+ const place = useRef({
32
+ atEnd: true,
33
+ first: void 0,
34
+ followKey
35
+ });
36
+ const remember = () => {
37
+ if (box.current && list.current) place.current = readPlace(box.current, list.current, followKey);
38
+ };
39
+ useLayoutEffect(() => {
40
+ const element = box.current;
41
+ if (!element || !list.current) return;
42
+ const before = place.current;
43
+ const anchorTop = before.anchor && before.anchor.element.isConnected ? before.anchor.element.getBoundingClientRect().top - element.getBoundingClientRect().top : void 0;
44
+ const next = scrollAfterChange(before, {
45
+ first: list.current.firstElementChild ?? void 0,
46
+ anchorTop,
47
+ scrollTop: element.scrollTop,
48
+ scrollHeight: element.scrollHeight,
49
+ followKey
50
+ });
51
+ if (next !== void 0) element.scrollTop = next;
52
+ remember();
53
+ }, [length, followKey]);
7
54
  useEffect(() => {
8
- const element = list.current;
9
- if (element) element.scrollTop = element.scrollHeight;
10
- }, [length]);
11
- return /* @__PURE__ */ jsx("div", {
12
- ref: list,
13
- role: "log",
14
- "aria-live": "polite",
15
- className: "flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4",
16
- children: length === 0 && empty ? /* @__PURE__ */ jsx("div", {
17
- className: "m-auto max-w-60 text-center text-muted-foreground",
18
- children: empty
19
- }) : children
55
+ const element = box.current;
56
+ if (!element || !list.current) return;
57
+ const observer = new ResizeObserver(() => {
58
+ if (place.current.atEnd) element.scrollTop = element.scrollHeight;
59
+ });
60
+ observer.observe(element);
61
+ observer.observe(list.current);
62
+ return () => observer.disconnect();
63
+ }, []);
64
+ const showsEmpty = length === 0 && Boolean(empty);
65
+ return /* @__PURE__ */ jsxs("div", {
66
+ ref: box,
67
+ onScroll: remember,
68
+ className: "flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-4 [overflow-anchor:none]",
69
+ children: [
70
+ onLoadEarlier && /* @__PURE__ */ jsx(Button, {
71
+ size: "sm",
72
+ className: "self-center",
73
+ loading: loadingEarlier,
74
+ onClick: onLoadEarlier,
75
+ children: text("loadEarlier")
76
+ }),
77
+ showsEmpty && /* @__PURE__ */ jsx("div", {
78
+ className: "m-auto max-w-60 text-center text-muted-foreground",
79
+ children: empty
80
+ }),
81
+ /* @__PURE__ */ jsx("div", {
82
+ ref: list,
83
+ role: "log",
84
+ "aria-live": "polite",
85
+ className: cn("flex flex-col gap-4", showsEmpty && "hidden"),
86
+ children
87
+ })
88
+ ]
20
89
  });
21
90
  };
22
91
  //#endregion
@@ -1,12 +1,20 @@
1
+ import { PeopleSearch } from "../mentions/use-people-search.js";
2
+ import "../mentions/index.js";
1
3
  import { Ref } from "react";
2
4
  //#region src/chat/composer.d.ts
3
5
  export type ComposerProps = {
4
- onSend?: (text: string) => void;
6
+ /** Gets the trimmed text and the ids of the people it mentions, each once. */
7
+ onSend?: (text: string, mentions: string[]) => void;
5
8
  /** The other side is still answering: the text can be written but not sent. */
6
9
  busy?: boolean;
7
10
  placeholder?: string;
8
- inputRef?: Ref<HTMLInputElement>;
11
+ inputRef?: Ref<HTMLTextAreaElement>;
12
+ /** Finds the people `@` lists by the name typed after it, such as a channel's members; without it, `@` is plain text. */
13
+ searchPeople?: PeopleSearch;
9
14
  };
10
- /** The text box under a chat. Enter or the button sends the trimmed text and clears the box. */
11
- export declare const Composer: ({ onSend, busy, placeholder, inputRef }: ComposerProps) => import("react").JSX.Element;
15
+ /**
16
+ * The text box under a chat, which grows with its text. Enter or the button sends the trimmed text and clears the box,
17
+ * Shift+Enter starts a new line; while the list of people to mention is open, Enter picks from it instead.
18
+ */
19
+ export declare const Composer: ({ onSend, busy, placeholder, inputRef, searchPeople }: ComposerProps) => import("react").JSX.Element;
12
20
  //#endregion
@@ -1,32 +1,47 @@
1
1
  import { Button } from "../primitives/button.js";
2
- import { Input } from "../primitives/input.js";
3
2
  import { text } from "./messages.js";
3
+ import { mentionedIds } from "../mentions/mention-text.js";
4
+ import { MentionBox, emptyDraft } from "../mentions/mention-box.js";
4
5
  import { useState } from "react";
5
6
  import { SendHorizontal } from "lucide-react";
6
7
  import { jsx, jsxs } from "react/jsx-runtime";
7
8
  //#region src/chat/composer.tsx
8
- /** The text box under a chat. Enter or the button sends the trimmed text and clears the box. */
9
- const Composer = ({ onSend, busy, placeholder, inputRef }) => {
10
- const [draft, setDraft] = useState("");
11
- const message = draft.trim();
9
+ /**
10
+ * The text box under a chat, which grows with its text. Enter or the button sends the trimmed text and clears the box,
11
+ * Shift+Enter starts a new line; while the list of people to mention is open, Enter picks from it instead.
12
+ */
13
+ const Composer = ({ onSend, busy, placeholder, inputRef, searchPeople }) => {
14
+ const [draft, setDraft] = useState(emptyDraft);
15
+ const message = draft.text.trim();
12
16
  const send = () => {
13
17
  if (!message || busy) return;
14
- onSend?.(message);
15
- setDraft("");
18
+ onSend?.(message, mentionedIds(message, draft.picked));
19
+ setDraft(emptyDraft);
20
+ };
21
+ const onKeyDown = (event) => {
22
+ if (event.key !== "Enter" || event.shiftKey || event.nativeEvent.isComposing) return;
23
+ event.preventDefault();
24
+ send();
16
25
  };
17
26
  return /* @__PURE__ */ jsxs("form", {
18
- className: "flex gap-2",
27
+ className: "flex items-end gap-2",
19
28
  onSubmit: (event) => {
20
29
  event.preventDefault();
21
30
  send();
22
31
  },
23
- children: [/* @__PURE__ */ jsx(Input, {
24
- ref: inputRef,
25
- "aria-label": text("message"),
26
- placeholder,
27
- value: draft,
28
- onChange: (event) => setDraft(event.target.value),
29
- wrapperClassName: "flex-1"
32
+ children: [/* @__PURE__ */ jsx("div", {
33
+ className: "min-w-0 flex-1",
34
+ children: /* @__PURE__ */ jsx(MentionBox, {
35
+ ref: inputRef,
36
+ rows: 1,
37
+ "aria-label": text("message"),
38
+ placeholder,
39
+ value: draft,
40
+ onChange: setDraft,
41
+ searchPeople,
42
+ listAt: "above",
43
+ onKeyDown
44
+ })
30
45
  }), /* @__PURE__ */ jsx(Button, {
31
46
  type: "submit",
32
47
  variant: "primary",
@@ -1,4 +1,6 @@
1
- import { ChatMessage } from "./chat-message.js";
1
+ import { chatDayLabel, sameChatDay } from "./chat-day.js";
2
+ import { ChatDivider, ChatDividerProps } from "./chat-divider.js";
3
+ import { ChatAuthor, ChatMessage, ChatMessageProps } from "./chat-message.js";
2
4
  import { ChatThread, ChatThreadProps } from "./chat-thread.js";
3
5
  import { Composer, ComposerProps } from "./composer.js";
4
- export { ChatMessage, ChatThread, type ChatThreadProps, Composer, type ComposerProps };
6
+ export { type ChatAuthor, ChatDivider, type ChatDividerProps, ChatMessage, type ChatMessageProps, ChatThread, type ChatThreadProps, Composer, type ComposerProps, chatDayLabel, sameChatDay };
@@ -3,11 +3,19 @@ import { defineMessages } from "../locale/messages.js";
3
3
  const text = defineMessages({
4
4
  en: {
5
5
  message: "Message",
6
- send: "Send"
6
+ send: "Send",
7
+ today: "Today",
8
+ yesterday: "Yesterday",
9
+ newMessages: "New messages",
10
+ loadEarlier: "Load earlier messages"
7
11
  },
8
12
  nl: {
9
13
  message: "Bericht",
10
- send: "Versturen"
14
+ send: "Versturen",
15
+ today: "Vandaag",
16
+ yesterday: "Gisteren",
17
+ newMessages: "Nieuwe berichten",
18
+ loadEarlier: "Eerdere berichten laden"
11
19
  }
12
20
  });
13
21
  //#endregion