@lotics/ui 1.20.0 → 1.22.0
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.
- package/package.json +2 -1
- package/src/card.tsx +1 -3
- package/src/combobox.tsx +1 -4
- package/src/comments_thread.tsx +378 -0
- package/src/date_field.tsx +1 -4
- package/src/index.css +0 -1
- package/src/picker.tsx +1 -4
- package/src/select_item.tsx +1 -3
- package/src/time_field.tsx +1 -4
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.22.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
"./tokens": "./src/tokens.ts",
|
|
7
7
|
"./colors": "./src/colors.ts",
|
|
8
8
|
"./mime": "./src/mime.ts",
|
|
9
9
|
"./download": "./src/download.ts",
|
|
10
|
+
"./comments_thread": "./src/comments_thread.tsx",
|
|
10
11
|
"./file_badge": "./src/file_badge.tsx",
|
|
11
12
|
"./file_thumbnail": "./src/file_thumbnail.tsx",
|
|
12
13
|
"./file_preview": {
|
package/src/card.tsx
CHANGED
package/src/combobox.tsx
CHANGED
|
@@ -155,10 +155,7 @@ const styles = StyleSheet.create({
|
|
|
155
155
|
cursor: "pointer",
|
|
156
156
|
},
|
|
157
157
|
opened: {
|
|
158
|
-
|
|
159
|
-
outlineWidth: 2,
|
|
160
|
-
outlineStyle: "solid",
|
|
161
|
-
outlineOffset: -2,
|
|
158
|
+
borderColor: colors.zinc["900"],
|
|
162
159
|
},
|
|
163
160
|
disabled: {
|
|
164
161
|
opacity: 0.5,
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { useMemo, useState, type ReactNode } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { Spacer } from "./spacer";
|
|
5
|
+
import { Stack } from "./stack";
|
|
6
|
+
import { Avatar } from "./avatar";
|
|
7
|
+
import { Button } from "./button";
|
|
8
|
+
import { IconButton } from "./icon_button";
|
|
9
|
+
import { MenuButton } from "./menu_button";
|
|
10
|
+
import { FileBadge } from "./file_badge";
|
|
11
|
+
import { TextInputField } from "./text_input_field";
|
|
12
|
+
import { Popover, PopoverTrigger, PopoverContent } from "./popover";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Presentational record-comments primitives — the view, edit, and delete of a
|
|
16
|
+
* comment thread, with every data, identity, file, and string concern injected.
|
|
17
|
+
* This is the one implementation shared by the product (`frontend`) and
|
|
18
|
+
* custom-code apps; neither owns a private copy of the per-comment rendering.
|
|
19
|
+
*
|
|
20
|
+
* Pure by the package contract: no data fetching, no i18n (strings come in via
|
|
21
|
+
* `labels`), no domain-type imports, no analytics. The consumer wires those —
|
|
22
|
+
* the product through its live-query + member directory, an app through
|
|
23
|
+
* `useComments` + `useMembers` from `@lotics/app-sdk`.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
export interface ThreadMember {
|
|
27
|
+
id: string;
|
|
28
|
+
name: string | null;
|
|
29
|
+
image?: string | null;
|
|
30
|
+
email?: string | null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ThreadFile {
|
|
34
|
+
id: string;
|
|
35
|
+
filename: string;
|
|
36
|
+
mime_type: string;
|
|
37
|
+
url?: string;
|
|
38
|
+
thumbnail_url?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ThreadComment {
|
|
42
|
+
id: string;
|
|
43
|
+
member_id: string;
|
|
44
|
+
content: string;
|
|
45
|
+
files?: ThreadFile[] | null;
|
|
46
|
+
created_at: string;
|
|
47
|
+
updated_at: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface CommentListLabels {
|
|
51
|
+
empty: string;
|
|
52
|
+
edit: string;
|
|
53
|
+
delete: string;
|
|
54
|
+
edited: string;
|
|
55
|
+
save: string;
|
|
56
|
+
cancel: string;
|
|
57
|
+
/** Accessible name for the per-comment actions trigger. */
|
|
58
|
+
actions: string;
|
|
59
|
+
/** Shown when a comment's author can't be resolved. */
|
|
60
|
+
unknownMember: string;
|
|
61
|
+
editPlaceholder: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const DEFAULT_LABELS: CommentListLabels = {
|
|
65
|
+
empty: "No comments yet.",
|
|
66
|
+
edit: "Edit",
|
|
67
|
+
delete: "Delete",
|
|
68
|
+
edited: "edited",
|
|
69
|
+
save: "Save",
|
|
70
|
+
cancel: "Cancel",
|
|
71
|
+
actions: "Comment actions",
|
|
72
|
+
unknownMember: "Unknown member",
|
|
73
|
+
editPlaceholder: "Edit comment…",
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Render-prop the consumer can supply to drive its own edit UI (e.g. with file editing). */
|
|
77
|
+
export interface CommentEditFormProps {
|
|
78
|
+
comment: ThreadComment;
|
|
79
|
+
onSave: (content: string, files?: ThreadFile[] | null) => void;
|
|
80
|
+
onCancel: () => void;
|
|
81
|
+
submitting: boolean;
|
|
82
|
+
labels: CommentListLabels;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface CommentListProps {
|
|
86
|
+
comments: ThreadComment[];
|
|
87
|
+
/** The viewer's member id — drives the author-only edit/delete affordance. Null hides it. */
|
|
88
|
+
currentMemberId: string | null;
|
|
89
|
+
resolveMember: (memberId: string) => ThreadMember | null;
|
|
90
|
+
/** Called on inline-edit save. Omit to make comments read-only (no edit affordance). */
|
|
91
|
+
onEdit?: (id: string, content: string, files?: ThreadFile[] | null) => void | Promise<void>;
|
|
92
|
+
/** Called on delete. Omit to hide the delete affordance. */
|
|
93
|
+
onDelete?: (id: string) => void | Promise<void>;
|
|
94
|
+
/** Render a comment's attachments. Defaults to a row of file badges + names. */
|
|
95
|
+
renderFiles?: (files: ThreadFile[]) => ReactNode;
|
|
96
|
+
/** Render the inline edit form. Defaults to a text-only editor (preserves existing files). */
|
|
97
|
+
renderEditForm?: (props: CommentEditFormProps) => ReactNode;
|
|
98
|
+
/** Format a comment's `created_at`. Defaults to the locale date-time string. */
|
|
99
|
+
formatTimestamp?: (iso: string) => string;
|
|
100
|
+
/** Shown when there are no comments. Defaults to the `empty` label. */
|
|
101
|
+
emptyState?: ReactNode;
|
|
102
|
+
labels?: Partial<CommentListLabels>;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function defaultFormatTimestamp(iso: string): string {
|
|
106
|
+
const d = new Date(iso);
|
|
107
|
+
return isNaN(d.getTime()) ? iso : d.toLocaleString();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function DefaultFiles({ files }: { files: ThreadFile[] }) {
|
|
111
|
+
return (
|
|
112
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", gap: 8 }}>
|
|
113
|
+
{files.map((f) => (
|
|
114
|
+
<View key={f.id} style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
115
|
+
<FileBadge mimeType={f.mime_type} />
|
|
116
|
+
<Text size="sm" numberOfLines={1}>
|
|
117
|
+
{f.filename}
|
|
118
|
+
</Text>
|
|
119
|
+
</View>
|
|
120
|
+
))}
|
|
121
|
+
</View>
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function DefaultEditForm(props: CommentEditFormProps) {
|
|
126
|
+
const { comment, onSave, onCancel, submitting, labels } = props;
|
|
127
|
+
const [content, setContent] = useState(comment.content);
|
|
128
|
+
const canSave = content.trim().length > 0 || (comment.files?.length ?? 0) > 0;
|
|
129
|
+
return (
|
|
130
|
+
<View>
|
|
131
|
+
<TextInputField
|
|
132
|
+
autoFocus
|
|
133
|
+
value={content}
|
|
134
|
+
onChangeText={setContent}
|
|
135
|
+
multiline
|
|
136
|
+
numberOfLines={3}
|
|
137
|
+
placeholder={labels.editPlaceholder}
|
|
138
|
+
/>
|
|
139
|
+
<Spacer size={8} />
|
|
140
|
+
<View style={{ flexDirection: "row", gap: 8, justifyContent: "flex-end" }}>
|
|
141
|
+
<Button title={labels.cancel} color="secondary" disabled={submitting} onPress={onCancel} />
|
|
142
|
+
<Button
|
|
143
|
+
title={labels.save}
|
|
144
|
+
color="primary"
|
|
145
|
+
disabled={!canSave || submitting}
|
|
146
|
+
loading={submitting}
|
|
147
|
+
// Preserve the comment's current files — a text edit never drops them.
|
|
148
|
+
onPress={() => onSave(content.trim(), comment.files)}
|
|
149
|
+
/>
|
|
150
|
+
</View>
|
|
151
|
+
</View>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface CommentRowProps {
|
|
156
|
+
comment: ThreadComment;
|
|
157
|
+
member: ThreadMember | null;
|
|
158
|
+
isOwn: boolean;
|
|
159
|
+
onEdit?: CommentListProps["onEdit"];
|
|
160
|
+
onDelete?: CommentListProps["onDelete"];
|
|
161
|
+
renderFiles: (files: ThreadFile[]) => ReactNode;
|
|
162
|
+
renderEditForm: (props: CommentEditFormProps) => ReactNode;
|
|
163
|
+
formatTimestamp: (iso: string) => string;
|
|
164
|
+
labels: CommentListLabels;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function CommentRow(props: CommentRowProps) {
|
|
168
|
+
const { comment, member, isOwn, onEdit, onDelete, renderFiles, renderEditForm } = props;
|
|
169
|
+
const { formatTimestamp, labels } = props;
|
|
170
|
+
const [isEditing, setIsEditing] = useState(false);
|
|
171
|
+
const [submitting, setSubmitting] = useState(false);
|
|
172
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
173
|
+
|
|
174
|
+
const displayName = member?.name || member?.email || labels.unknownMember;
|
|
175
|
+
const imageSrc = member?.image ? { uri: member.image } : undefined;
|
|
176
|
+
const files = comment.files ?? [];
|
|
177
|
+
const showActions = isOwn && !isEditing && (onEdit != null || onDelete != null);
|
|
178
|
+
|
|
179
|
+
const handleSave = async (content: string, savedFiles?: ThreadFile[] | null) => {
|
|
180
|
+
if (!onEdit) return;
|
|
181
|
+
setSubmitting(true);
|
|
182
|
+
try {
|
|
183
|
+
await onEdit(comment.id, content, savedFiles);
|
|
184
|
+
setIsEditing(false);
|
|
185
|
+
} catch {
|
|
186
|
+
// The data layer owns rollback + surfacing (the optimistic edit reverts);
|
|
187
|
+
// keep the form open for retry and don't let the rejection go unhandled.
|
|
188
|
+
} finally {
|
|
189
|
+
setSubmitting(false);
|
|
190
|
+
}
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const handleDelete = async () => {
|
|
194
|
+
if (!onDelete || submitting) return;
|
|
195
|
+
setMenuOpen(false);
|
|
196
|
+
setSubmitting(true);
|
|
197
|
+
try {
|
|
198
|
+
await onDelete(comment.id);
|
|
199
|
+
} catch {
|
|
200
|
+
// Rollback re-shows the row (the visible feedback); swallow the rejection.
|
|
201
|
+
} finally {
|
|
202
|
+
setSubmitting(false);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<View style={{ flexDirection: "row", gap: 8 }}>
|
|
208
|
+
<View style={{ flex: 1, flexDirection: "row", gap: 8 }}>
|
|
209
|
+
<View style={{ paddingTop: 4 }}>
|
|
210
|
+
<Avatar source={imageSrc} name={displayName} />
|
|
211
|
+
</View>
|
|
212
|
+
<View style={{ flex: 1 }}>
|
|
213
|
+
<Text weight="medium">{displayName}</Text>
|
|
214
|
+
<Text size="sm" color="zinc-500">
|
|
215
|
+
{formatTimestamp(comment.created_at)}
|
|
216
|
+
{comment.updated_at !== comment.created_at ? ` · ${labels.edited}` : ""}
|
|
217
|
+
</Text>
|
|
218
|
+
<Spacer size={8} />
|
|
219
|
+
{isEditing ? (
|
|
220
|
+
renderEditForm({
|
|
221
|
+
comment,
|
|
222
|
+
onSave: handleSave,
|
|
223
|
+
onCancel: () => setIsEditing(false),
|
|
224
|
+
submitting,
|
|
225
|
+
labels,
|
|
226
|
+
})
|
|
227
|
+
) : (
|
|
228
|
+
<View>
|
|
229
|
+
{comment.content ? <Text>{comment.content}</Text> : null}
|
|
230
|
+
{files.length > 0 ? (
|
|
231
|
+
<>
|
|
232
|
+
{comment.content ? <Spacer size={8} /> : null}
|
|
233
|
+
{renderFiles(files)}
|
|
234
|
+
</>
|
|
235
|
+
) : null}
|
|
236
|
+
</View>
|
|
237
|
+
)}
|
|
238
|
+
</View>
|
|
239
|
+
</View>
|
|
240
|
+
{showActions ? (
|
|
241
|
+
<Popover open={menuOpen} onOpenChange={setMenuOpen}>
|
|
242
|
+
<PopoverTrigger>
|
|
243
|
+
<IconButton icon="ellipsis" tooltip={labels.actions} />
|
|
244
|
+
</PopoverTrigger>
|
|
245
|
+
<PopoverContent>
|
|
246
|
+
<Stack>
|
|
247
|
+
{onEdit ? (
|
|
248
|
+
<MenuButton
|
|
249
|
+
icon="pencil"
|
|
250
|
+
title={labels.edit}
|
|
251
|
+
onPress={() => {
|
|
252
|
+
setMenuOpen(false);
|
|
253
|
+
setIsEditing(true);
|
|
254
|
+
}}
|
|
255
|
+
/>
|
|
256
|
+
) : null}
|
|
257
|
+
{onDelete ? (
|
|
258
|
+
<MenuButton
|
|
259
|
+
icon="trash"
|
|
260
|
+
title={labels.delete}
|
|
261
|
+
danger
|
|
262
|
+
disabled={submitting}
|
|
263
|
+
onPress={handleDelete}
|
|
264
|
+
/>
|
|
265
|
+
) : null}
|
|
266
|
+
</Stack>
|
|
267
|
+
</PopoverContent>
|
|
268
|
+
</Popover>
|
|
269
|
+
) : null}
|
|
270
|
+
</View>
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The comment list — one author/avatar/timestamp/content/files row per comment,
|
|
276
|
+
* oldest first, with an author-only edit/delete menu. Pair it with a composer
|
|
277
|
+
* (`CommentComposer`, or a richer consumer-owned one) for the full thread.
|
|
278
|
+
*/
|
|
279
|
+
export function CommentList(props: CommentListProps) {
|
|
280
|
+
const labels = { ...DEFAULT_LABELS, ...props.labels };
|
|
281
|
+
const renderFiles = props.renderFiles ?? ((files: ThreadFile[]) => <DefaultFiles files={files} />);
|
|
282
|
+
const renderEditForm = props.renderEditForm ?? ((p: CommentEditFormProps) => <DefaultEditForm {...p} />);
|
|
283
|
+
const formatTimestamp = props.formatTimestamp ?? defaultFormatTimestamp;
|
|
284
|
+
|
|
285
|
+
const sorted = useMemo(
|
|
286
|
+
() =>
|
|
287
|
+
[...props.comments].sort(
|
|
288
|
+
(a, b) => new Date(a.created_at).getTime() - new Date(b.created_at).getTime(),
|
|
289
|
+
),
|
|
290
|
+
[props.comments],
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
if (sorted.length === 0) {
|
|
294
|
+
return <>{props.emptyState ?? <Text color="zinc-500">{labels.empty}</Text>}</>;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return (
|
|
298
|
+
<View>
|
|
299
|
+
{sorted.map((comment, i) => (
|
|
300
|
+
<View key={comment.id}>
|
|
301
|
+
{i > 0 ? <Spacer size={16} /> : null}
|
|
302
|
+
<CommentRow
|
|
303
|
+
comment={comment}
|
|
304
|
+
member={props.resolveMember(comment.member_id)}
|
|
305
|
+
isOwn={props.currentMemberId != null && props.currentMemberId === comment.member_id}
|
|
306
|
+
onEdit={props.onEdit}
|
|
307
|
+
onDelete={props.onDelete}
|
|
308
|
+
renderFiles={renderFiles}
|
|
309
|
+
renderEditForm={renderEditForm}
|
|
310
|
+
formatTimestamp={formatTimestamp}
|
|
311
|
+
labels={labels}
|
|
312
|
+
/>
|
|
313
|
+
</View>
|
|
314
|
+
))}
|
|
315
|
+
</View>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export interface CommentComposerProps {
|
|
320
|
+
/** Submit a new comment. Cleared on success; left intact on throw so the draft survives. */
|
|
321
|
+
onSubmit: (content: string) => void | Promise<void>;
|
|
322
|
+
placeholder: string;
|
|
323
|
+
sendLabel: string;
|
|
324
|
+
/** Disable input + send (e.g. comments unavailable for an anonymous viewer). */
|
|
325
|
+
disabled?: boolean;
|
|
326
|
+
/** Extra controls left of the send button — e.g. an attach-file button. */
|
|
327
|
+
actions?: ReactNode;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* A minimal text comment composer for consumers without a richer one of their
|
|
332
|
+
* own (the product uses its file-capable chat composer instead). Multiline
|
|
333
|
+
* input + a send button; attachments are opt-in via the `actions` slot.
|
|
334
|
+
*/
|
|
335
|
+
export function CommentComposer(props: CommentComposerProps) {
|
|
336
|
+
const { onSubmit, placeholder, sendLabel, disabled, actions } = props;
|
|
337
|
+
const [content, setContent] = useState("");
|
|
338
|
+
const [sending, setSending] = useState(false);
|
|
339
|
+
|
|
340
|
+
const send = async () => {
|
|
341
|
+
const text = content.trim();
|
|
342
|
+
if (!text || sending || disabled) return;
|
|
343
|
+
setSending(true);
|
|
344
|
+
try {
|
|
345
|
+
await onSubmit(text);
|
|
346
|
+
setContent("");
|
|
347
|
+
} catch {
|
|
348
|
+
// Keep the draft so the user can retry; the data layer surfaces the
|
|
349
|
+
// failure (the optimistic comment rolls back). Don't propagate.
|
|
350
|
+
} finally {
|
|
351
|
+
setSending(false);
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
return (
|
|
356
|
+
<View>
|
|
357
|
+
<TextInputField
|
|
358
|
+
value={content}
|
|
359
|
+
onChangeText={setContent}
|
|
360
|
+
placeholder={placeholder}
|
|
361
|
+
editable={!disabled}
|
|
362
|
+
multiline
|
|
363
|
+
numberOfLines={2}
|
|
364
|
+
/>
|
|
365
|
+
<Spacer size={8} />
|
|
366
|
+
<View style={{ flexDirection: "row", gap: 8, justifyContent: "flex-end", alignItems: "center" }}>
|
|
367
|
+
{actions}
|
|
368
|
+
<Button
|
|
369
|
+
title={sendLabel}
|
|
370
|
+
color="primary"
|
|
371
|
+
disabled={disabled || !content.trim() || sending}
|
|
372
|
+
loading={sending}
|
|
373
|
+
onPress={send}
|
|
374
|
+
/>
|
|
375
|
+
</View>
|
|
376
|
+
</View>
|
|
377
|
+
);
|
|
378
|
+
}
|
package/src/date_field.tsx
CHANGED
|
@@ -150,10 +150,7 @@ const styles = StyleSheet.create({
|
|
|
150
150
|
backgroundColor: colors.background,
|
|
151
151
|
},
|
|
152
152
|
frameFocused: {
|
|
153
|
-
|
|
154
|
-
outlineWidth: 2,
|
|
155
|
-
outlineStyle: "solid",
|
|
156
|
-
outlineOffset: -2,
|
|
153
|
+
borderColor: colors.zinc["900"],
|
|
157
154
|
},
|
|
158
155
|
frameDisabled: {
|
|
159
156
|
backgroundColor: colors.zinc["50"],
|
package/src/index.css
CHANGED
package/src/picker.tsx
CHANGED
|
@@ -331,10 +331,7 @@ const styles = StyleSheet.create({
|
|
|
331
331
|
height: 40,
|
|
332
332
|
},
|
|
333
333
|
opened: {
|
|
334
|
-
|
|
335
|
-
outlineWidth: 2,
|
|
336
|
-
outlineStyle: "solid",
|
|
337
|
-
outlineOffset: -2,
|
|
334
|
+
borderColor: colors.zinc["900"],
|
|
338
335
|
},
|
|
339
336
|
disabled: {
|
|
340
337
|
opacity: 0.5,
|
package/src/select_item.tsx
CHANGED
package/src/time_field.tsx
CHANGED
|
@@ -273,10 +273,7 @@ const styles = StyleSheet.create({
|
|
|
273
273
|
backgroundColor: colors.background,
|
|
274
274
|
},
|
|
275
275
|
triggerOpen: {
|
|
276
|
-
|
|
277
|
-
outlineWidth: 2,
|
|
278
|
-
outlineStyle: "solid",
|
|
279
|
-
outlineOffset: -2,
|
|
276
|
+
borderColor: colors.zinc["900"],
|
|
280
277
|
},
|
|
281
278
|
triggerDisabled: {
|
|
282
279
|
backgroundColor: colors.zinc["50"],
|