From b503304ba25a6a4b67218bd5d72e4fdcd16daeea Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 19:44:32 +0000 Subject: [PATCH 1/3] feat(app-shell): tell the comment author which mentioned users will not be notified The adapter's create/update take an optional per-call onMentionsWithheld listener that hears the write answer's withheldMentions; the record page's three comment writes name those users in one sentence, mapped through the picker's suggestions. The composer draws picked mentions as chips. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibvjxjPWDhWVKzkEU2x --- .../app-shell/src/views/RecordDetailView.tsx | 34 +++++++-- ui/packages/data-objectstack/src/index.ts | 47 ++++++++++++- ui/packages/i18n/src/locales/ar.ts | 2 + ui/packages/i18n/src/locales/de.ts | 2 + ui/packages/i18n/src/locales/en.ts | 5 ++ ui/packages/i18n/src/locales/es.ts | 2 + ui/packages/i18n/src/locales/fr.ts | 2 + ui/packages/i18n/src/locales/ja.ts | 2 + ui/packages/i18n/src/locales/ko.ts | 2 + ui/packages/i18n/src/locales/pt.ts | 2 + ui/packages/i18n/src/locales/ru.ts | 2 + ui/packages/i18n/src/locales/zh.ts | 2 + .../src/RichTextCommentInput.tsx | 70 ++++++++++++++++--- .../plugin-detail/src/useDetailTranslation.ts | 1 + 14 files changed, 157 insertions(+), 18 deletions(-) diff --git a/ui/packages/app-shell/src/views/RecordDetailView.tsx b/ui/packages/app-shell/src/views/RecordDetailView.tsx index da9115968bb..6cf08c0a438 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.tsx @@ -2957,6 +2957,28 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri [t], ); + /** + * Say in one sentence who a comment write's mentions will not reach (ruling A + * on objectstack#27703). The server withholds the notification of a mentioned + * user the mention gate refuses and answers their ids to the author's own + * write as `withheldMentions`; without this the author believes they were + * told. The ids map to names through the picker's suggestions, the list the + * write's own `mentions` were extracted from. No reason is given: the + * disclosure is one bit per user the author named. + */ + const tellMentionsWithheld = useCallback( + (ids: string[], suggestions: ReadonlyArray<{ id: string; label: string }>) => { + const names = ids.map(id => suggestions.find(s => s.id === id)?.label || id); + toast.warning( + t('detail.mentionsWithheld', { + defaultValue: '{{names}} will not be notified of this comment.', + names: names.join(t('validation.formInvalidJoiner', { defaultValue: ', ' })), + }), + ); + }, + [t], + ); + const handleAddComment = useCallback( async (text: string) => { if (!dataSource || !feedRecordKey) { @@ -2986,7 +3008,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri body: text, mentions: JSON.stringify(mentionIds), created_at: newItem.createdAt, - }); + }, { onMentionsWithheld: (ids: string[]) => tellMentionsWithheld(ids, mentionSuggestions) }); } catch (err) { throw commentWriteFailed(err); } @@ -2996,7 +3018,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri [key]: [...(prev[key] ?? EMPTY_FEED), newItem], })); }, - [currentUser, dataSource, objectName, pureRecordId, feedRecordKey, mentionSuggestions, commentWriteFailed], + [currentUser, dataSource, objectName, pureRecordId, feedRecordKey, mentionSuggestions, commentWriteFailed, tellMentionsWithheld], ); const handleAddReply = useCallback( @@ -3027,7 +3049,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri mentions: JSON.stringify(mentionIds), created_at: newItem.createdAt, parent_id: parentId, - }); + }, { onMentionsWithheld: (ids: string[]) => tellMentionsWithheld(ids, mentionSuggestions) }); } catch (err) { throw commentWriteFailed(err); } @@ -3047,7 +3069,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri }; }); }, - [currentUser, dataSource, objectName, pureRecordId, feedRecordKey, mentionSuggestions, commentWriteFailed], + [currentUser, dataSource, objectName, pureRecordId, feedRecordKey, mentionSuggestions, commentWriteFailed, tellMentionsWithheld], ); /** @@ -3095,7 +3117,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri stored = await dataSource.update(COMMENT_OBJECT, String(itemId), { body: text, mentions: JSON.stringify(extractMentions(text, mentionSuggestions)), - }); + }, { onMentionsWithheld: (ids: string[]) => tellMentionsWithheld(ids, mentionSuggestions) }); } catch (err) { throw editFailed(err); } @@ -3107,7 +3129,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri ), })); }, - [dataSource, feedRecordKey, feedItemsByRecord, mentionSuggestions, t], + [dataSource, feedRecordKey, feedItemsByRecord, mentionSuggestions, t, tellMentionsWithheld], ); /** diff --git a/ui/packages/data-objectstack/src/index.ts b/ui/packages/data-objectstack/src/index.ts index b22865c6f5d..20fba8d20e6 100644 --- a/ui/packages/data-objectstack/src/index.ts +++ b/ui/packages/data-objectstack/src/index.ts @@ -2279,6 +2279,29 @@ function asDroppedFieldsNotice( */ const UNATTRIBUTED_STRIP_OBJECT = ''; +/** + * Hand a write answer's `withheldMentions` (objectstack#27703) to the caller's + * per-call listener: the string ids, once, and only when there is at least + * one. A runtime that predates the key, or a write that withheld nobody, + * calls nothing. Isolated like the subscriber fan-outs: the write has already + * succeeded, so a throwing listener must not turn it into a rejection. + */ +function notifyWithheldMentions( + result: unknown, + listener: ((ids: string[]) => void) | undefined, +): void { + if (!listener) return; + const wire = (result as { withheldMentions?: unknown } | null | undefined)?.withheldMentions; + if (!Array.isArray(wire)) return; + const ids = wire.filter((id): id is string => typeof id === 'string' && id !== ''); + if (ids.length === 0) return; + try { + listener(ids); + } catch (err) { + console.warn('ObjectStackAdapter: withheld-mentions listener error', err); + } +} + /** * Emitted after a create/update whose response carried `droppedFields` * (objectstack-ai/objectstack#3431 / objectstack-ai/objectstack#3455) or @@ -4411,7 +4434,22 @@ export class ObjectStackAdapter implements DataSource { return this.client.meta.getItems('view'); } - async create(resource: string, data: Partial): Promise { + /** + * Create a record. + * + * Optional `opts.onMentionsWithheld` hears the write answer's + * `withheldMentions` (objectstack#27703): the ids of the users this + * `sys_comment` write @mentioned whose notification the server withheld. + * It is called once, after the write succeeded, and only when the answer + * names at least one id. The answer is the requester's own and is never + * stored, so this per-call listener is the only door to it; the resolved + * value stays the record, as for every other caller. + */ + async create( + resource: string, + data: Partial, + opts?: { onMentionsWithheld?: (ids: string[]) => void }, + ): Promise { await this.connect(); try { const result = await this.client.data.create(resource, data); @@ -4423,6 +4461,7 @@ export class ObjectStackAdapter implements DataSource { (result.record as { id?: string | number } | undefined)?.id, data as Record, ); + notifyWithheldMentions(result, opts?.onMentionsWithheld); return result.record; } catch (err) { // `update` has always normalised; `create` did not, so a rejected insert @@ -4443,12 +4482,15 @@ export class ObjectStackAdapter implements DataSource { * * Requires `@objectstack/client@>=4.2.0`, which forwards `opts.ifMatch` * as an `If-Match` HTTP header. + * + * Optional `opts.onMentionsWithheld` hears the answer's `withheldMentions`, + * as on {@link create}: on an update, only for a mention the update added. */ async update( resource: string, id: string | number, data: Partial, - opts?: { ifMatch?: string }, + opts?: { ifMatch?: string; onMentionsWithheld?: (ids: string[]) => void }, ): Promise { await this.connect(); try { @@ -4462,6 +4504,7 @@ export class ObjectStackAdapter implements DataSource { // the protocol id, whatever this method's wider parameter admitted. this.emitMutation({ type: 'update', resource, id: String(id), record: { ...result.record } }); this.notifyDroppedFields('update', resource, result, id, data as Record); + notifyWithheldMentions(result, opts?.onMentionsWithheld); return result.record; } catch (err) { throw normaliseClientError(err); diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index d1454ad5d6a..2f1ea3bb632 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -1362,6 +1362,7 @@ const ar = { listFormat: "قائمة", inlineCode: "كود مضمن", mentionSomeone: "إشارة إلى شخص", + mentionedPeople: "الأشخاص المُشار إليهم", preview: "معاينة", submitComment: "إرسال (Ctrl+Enter)", sendComment: "إرسال", @@ -1464,6 +1465,7 @@ const ar = { deleteCommentConfirm: "هل أنت متأكد أنك تريد حذف هذا التعليق؟", commentEditFailed: "لم يُحفظ تعديلك. يحتفظ التعليق بنصه السابق.", commentDeleteFailed: "لم يُحذف التعليق. يُرجى المحاولة مرة أخرى.", + mentionsWithheld: "لن يُرسَل إشعار بهذا التعليق إلى {{names}}.", linkCopyFailed: "فشل نسخ الرابط", claim: "استلام", claimed: "أصبحت مالك هذا السجل الآن.", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index fddf61a9b36..02e7f374228 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -1216,6 +1216,7 @@ const de = { listFormat: "Liste", inlineCode: "Inline-Code", mentionSomeone: "Jemanden erwähnen", + mentionedPeople: "Erwähnt", preview: "Vorschau", submitComment: "Senden (Strg+Enter)", sendComment: "Senden", @@ -1306,6 +1307,7 @@ const de = { deleteCommentConfirm: "Möchten Sie diesen Kommentar wirklich löschen?", commentEditFailed: "Ihre Änderung wurde nicht gespeichert. Der Kommentar behält seinen bisherigen Text.", commentDeleteFailed: "Der Kommentar wurde nicht gelöscht. Bitte versuchen Sie es erneut.", + mentionsWithheld: "Über diesen Kommentar wird keine Benachrichtigung an {{names}} gesendet.", linkCopyFailed: "Link konnte nicht kopiert werden", claim: "Übernehmen", claimed: "Dieser Datensatz gehört jetzt Ihnen.", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 76c296d4ee3..3bf12ae52a9 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -1437,6 +1437,9 @@ const en = { deleteCommentConfirm: 'Are you sure you want to delete this comment?', commentEditFailed: 'Your edit was not saved. The comment keeps its previous text.', commentDeleteFailed: 'The comment was not deleted. Please try again.', + // objectstack#28218 — a comment write whose answer names mentioned users the + // server will not notify (`withheldMentions`): one sentence naming them, no reason. + mentionsWithheld: '{{names}} will not be notified of this comment.', linkCopyFailed: 'Failed to copy link', claim: 'Claim', claimed: 'You own this record now.', @@ -1630,6 +1633,8 @@ const en = { listFormat: 'List', inlineCode: 'Inline code', mentionSomeone: 'Mention someone', + // objectstack#28218 — the composer's row of picked-mention chips. + mentionedPeople: 'Mentioned', preview: 'Preview', submitComment: 'Submit (Ctrl+Enter)', sendComment: 'Send', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 08831d2f649..f5c371ad4aa 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -1254,6 +1254,7 @@ const es = { listFormat: "Lista", inlineCode: "Código en línea", mentionSomeone: "Mencionar a alguien", + mentionedPeople: "Personas mencionadas", preview: "Vista previa", submitComment: "Enviar (Ctrl+Enter)", sendComment: "Enviar", @@ -1347,6 +1348,7 @@ const es = { deleteCommentConfirm: "¿Está seguro de que desea eliminar este comentario?", commentEditFailed: "Tu edición no se ha guardado. El comentario conserva su texto anterior.", commentDeleteFailed: "El comentario no se ha eliminado. Inténtalo de nuevo.", + mentionsWithheld: "No se enviará ninguna notificación de este comentario a {{names}}.", linkCopyFailed: "No se pudo copiar el enlace", claim: "Reclamar", claimed: "Ahora este registro es tuyo.", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 6d4364c382c..86b67c0406c 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -1251,6 +1251,7 @@ const fr = { listFormat: "Liste", inlineCode: "Code en ligne", mentionSomeone: "Mentionner quelqu'un", + mentionedPeople: "Personnes mentionnées", preview: "Aperçu", submitComment: "Envoyer (Ctrl+Entrée)", sendComment: "Envoyer", @@ -1344,6 +1345,7 @@ const fr = { deleteCommentConfirm: "Êtes-vous sûr de vouloir supprimer ce commentaire ?", commentEditFailed: "Votre modification n'a pas été enregistrée. Le commentaire conserve son texte précédent.", commentDeleteFailed: "Le commentaire n'a pas été supprimé. Veuillez réessayer.", + mentionsWithheld: "Aucune notification de ce commentaire ne sera envoyée à {{names}}.", linkCopyFailed: "Impossible de copier le lien", claim: "Prendre en charge", claimed: "Cet enregistrement vous appartient désormais.", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 0cb90298a75..0db9f19ecbd 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -1268,6 +1268,7 @@ const ja = { listFormat: "リスト", inlineCode: "インラインコード", mentionSomeone: "メンションする", + mentionedPeople: "メンション先", preview: "プレビュー", submitComment: "送信 (Ctrl+Enter)", sendComment: "送信", @@ -1358,6 +1359,7 @@ const ja = { deleteCommentConfirm: "このコメントを削除してもよろしいですか?", commentEditFailed: "編集内容は保存されませんでした。コメントは以前の内容のままです。", commentDeleteFailed: "コメントは削除されませんでした。もう一度お試しください。", + mentionsWithheld: "{{names}} にはこのコメントの通知が届きません。", linkCopyFailed: "リンクのコピーに失敗しました", claim: "担当する", claimed: "このレコードの担当者になりました。", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 0d535b02beb..0e6acd4fc17 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -1216,6 +1216,7 @@ const ko = { listFormat: "목록", inlineCode: "인라인 코드", mentionSomeone: "멘션하기", + mentionedPeople: "멘션한 사람", preview: "미리보기", submitComment: "제출 (Ctrl+Enter)", sendComment: "보내기", @@ -1306,6 +1307,7 @@ const ko = { deleteCommentConfirm: "이 댓글을 삭제하시겠습니까?", commentEditFailed: "수정 내용이 저장되지 않았습니다. 댓글은 이전 내용을 유지합니다.", commentDeleteFailed: "댓글이 삭제되지 않았습니다. 다시 시도해 주세요.", + mentionsWithheld: "{{names}}에게는 이 댓글의 알림이 전송되지 않습니다.", linkCopyFailed: "링크 복사 실패", claim: "담당하기", claimed: "이제 이 레코드의 담당자입니다.", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 06ad6b4b40b..33b8eaec725 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -1251,6 +1251,7 @@ const pt = { listFormat: "Lista", inlineCode: "Código inline", mentionSomeone: "Mencionar alguém", + mentionedPeople: "Pessoas mencionadas", preview: "Pré-visualização", submitComment: "Enviar (Ctrl+Enter)", sendComment: "Enviar", @@ -1344,6 +1345,7 @@ const pt = { deleteCommentConfirm: "Tem certeza de que deseja excluir este comentário?", commentEditFailed: "Sua edição não foi salva. O comentário mantém o texto anterior.", commentDeleteFailed: "O comentário não foi excluído. Tente novamente.", + mentionsWithheld: "Nenhuma notificação deste comentário será enviada para {{names}}.", linkCopyFailed: "Falha ao copiar o link", claim: "Assumir", claimed: "Este registro agora é seu.", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index c6971a12436..02bf88c533e 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -1355,6 +1355,7 @@ const ru = { listFormat: "Список", inlineCode: "Строчный код", mentionSomeone: "Упомянуть кого-то", + mentionedPeople: "Упомянутые", preview: "Предпросмотр", submitComment: "Отправить (Ctrl+Enter)", sendComment: "Отправить", @@ -1451,6 +1452,7 @@ const ru = { deleteCommentConfirm: "Вы уверены, что хотите удалить этот комментарий?", commentEditFailed: "Изменения не сохранены. Комментарий сохраняет прежний текст.", commentDeleteFailed: "Комментарий не удалён. Попробуйте ещё раз.", + mentionsWithheld: "Уведомление об этом комментарии не будет отправлено: {{names}}.", linkCopyFailed: "Не удалось скопировать ссылку", claim: "Взять в работу", claimed: "Теперь эта запись ваша.", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index ace362120e2..d02934a0299 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -1186,6 +1186,7 @@ const zh = { deleteCommentConfirm: '确定要删除这条评论吗?', commentEditFailed: '您的修改未保存,评论保留原有内容。', commentDeleteFailed: '评论未删除,请重试。', + mentionsWithheld: '{{names}} 不会收到这条评论的通知。', linkCopyFailed: '复制链接失败', claim: '认领', claimed: '这条记录现在归你了。', @@ -1365,6 +1366,7 @@ const zh = { listFormat: '列表', inlineCode: '行内代码', mentionSomeone: '提及某人', + mentionedPeople: '已提及', preview: '预览', submitComment: '提交 (Ctrl+Enter)', sendComment: '发送', diff --git a/ui/packages/plugin-detail/src/RichTextCommentInput.tsx b/ui/packages/plugin-detail/src/RichTextCommentInput.tsx index 3d49ea05697..96022f115d0 100644 --- a/ui/packages/plugin-detail/src/RichTextCommentInput.tsx +++ b/ui/packages/plugin-detail/src/RichTextCommentInput.tsx @@ -19,6 +19,7 @@ import { Send, } from 'lucide-react'; import { useDetailTranslation } from './useDetailTranslation'; +import { extractMentions } from './extractMentions'; export interface MentionSuggestion { id: string; @@ -43,13 +44,30 @@ export interface RichTextCommentInputProps { canSubmitEmpty?: boolean; } -/** Render minimal markdown to HTML for preview. */ -function renderMarkdown(text: string): string { - let html = text - // Escape HTML - .replace(/&/g, '&') - .replace(//g, '>') +/** The look of a picked mention, in the preview and in the composer's chip row. */ +const MENTION_CHIP_CLASS = + 'inline-flex items-center rounded-full bg-primary/10 px-1.5 text-primary font-medium'; + +function escapeHtml(text: string): string { + return text.replace(/&/g, '&').replace(//g, '>'); +} + +/** + * Render minimal markdown to HTML for preview. `@LABEL` of a picked mention + * (a label in `mentionLabels`, the suggestions the write resolves mentions + * through) is drawn as a chip, longest label first and only where no letter + * or digit follows, as `extractMentions` matches it; any other `@word` keeps + * the plain highlight. + */ +function renderMarkdown(text: string, mentionLabels: readonly string[] = []): string { + const picked = [...mentionLabels] + .filter(Boolean) + .sort((a, b) => b.length - a.length) + .map((label) => escapeHtml(label).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); + const mentionRe = picked.length + ? new RegExp(`@(?:(${picked.join('|')})(?![\\p{L}\\p{N}_])|(\\w+))`, 'gu') + : /@()(\w+)/g; + let html = escapeHtml(text) // Code blocks (```) .replace(/```([\s\S]*?)```/g, '
$1
') // Inline code @@ -58,8 +76,12 @@ function renderMarkdown(text: string): string { .replace(/\*\*(.+?)\*\*/g, '$1') // Italic .replace(/\*(.+?)\*/g, '$1') - // @mentions - .replace(/@(\w+)/g, '@$1') + // @mentions: a picked one as a chip, any other `@word` highlighted + .replace(mentionRe, (_match: string, label: string | undefined, word: string) => + label + ? `@${label}` + : `@${word}`, + ) // Unordered lists .replace(/^- (.+)$/gm, '
  • $1
  • ') // Line breaks @@ -92,6 +114,17 @@ export const RichTextCommentInput: React.FC = ({ const [mentionIndex, setMentionIndex] = React.useState(0); const textareaRef = React.useRef(null); + // The mentions the text holds, resolved the way the write resolves them + // (`extractMentions`), drawn as chips under the editor. + const pickedMentions = React.useMemo(() => { + const ids = new Set(extractMentions(value, mentionSuggestions)); + return mentionSuggestions.filter((s) => ids.has(s.id)); + }, [value, mentionSuggestions]); + const mentionLabels = React.useMemo( + () => mentionSuggestions.map((s) => s.label), + [mentionSuggestions], + ); + const filteredMentions = React.useMemo(() => { if (!mentionQuery) return mentionSuggestions; const query = mentionQuery.toLowerCase(); @@ -305,7 +338,7 @@ export const RichTextCommentInput: React.FC = ({ {isPreview ? (
    ) : ( <> @@ -319,6 +352,23 @@ export const RichTextCommentInput: React.FC = ({ disabled={disabled} /> + {pickedMentions.length > 0 && ( +
      + {pickedMentions.map((s) => ( +
    • + {s.avatarUrl ? ( + + ) : null} + @{s.label} +
    • + ))} +
    + )} + {/* @mention dropdown */} {showMentions && filteredMentions.length > 0 && (
    diff --git a/ui/packages/plugin-detail/src/useDetailTranslation.ts b/ui/packages/plugin-detail/src/useDetailTranslation.ts index ea5ca4f66f6..9c090472682 100644 --- a/ui/packages/plugin-detail/src/useDetailTranslation.ts +++ b/ui/packages/plugin-detail/src/useDetailTranslation.ts @@ -164,6 +164,7 @@ export const DETAIL_DEFAULT_TRANSLATIONS: Record = { 'detail.listFormat': 'List', 'detail.inlineCode': 'Inline code', 'detail.mentionSomeone': 'Mention someone', + 'detail.mentionedPeople': 'Mentioned', 'detail.preview': 'Preview', 'detail.submitComment': 'Submit (Ctrl+Enter)', 'detail.sendComment': 'Send', From 08c520e338aaf96c57a4be9367c1bca9c418b7b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 19:59:09 +0000 Subject: [PATCH 2/3] test(app-shell): pin the withheld-mention sentence on the record page's three comment writes, and the composer's mention chips Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibvjxjPWDhWVKzkEU2x --- ...ailView.commentWriteFailure-10899.test.tsx | 3 + .../RecordDetailView.feedRecordScope.test.tsx | 2 + ...DetailView.withheldMentions-28218.test.tsx | 299 ++++++++++++++++++ .../src/withheldMentions-28218.test.ts | 104 ++++++ ...xtCommentInput.mentionChips-28218.test.tsx | 83 +++++ 5 files changed, 491 insertions(+) create mode 100644 ui/packages/app-shell/src/views/RecordDetailView.withheldMentions-28218.test.tsx create mode 100644 ui/packages/data-objectstack/src/withheldMentions-28218.test.ts create mode 100644 ui/packages/plugin-detail/src/__tests__/RichTextCommentInput.mentionChips-28218.test.tsx diff --git a/ui/packages/app-shell/src/views/RecordDetailView.commentWriteFailure-10899.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.commentWriteFailure-10899.test.tsx index 8b3445628a3..fe3e0894573 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.commentWriteFailure-10899.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.commentWriteFailure-10899.test.tsx @@ -177,6 +177,8 @@ describe('a failed comment write is never rendered as sent (objectui#10899)', () expect(dataSource.create).toHaveBeenCalledWith( 'sys_comment', expect.objectContaining({ thread_id: `${OBJECT_NAME}:${RECORD_ID}`, body: 'Called the customer, follow up Friday' }), + // The per-call listener for the answer's `withheldMentions` (objectstack#28218). + { onMentionsWithheld: expect.any(Function) }, ); // The panel still says there is nothing — the comment is not "sent". expect(feedRow('Called the customer, follow up Friday')).toBeNull(); @@ -243,6 +245,7 @@ describe('a failed comment write is never rendered as sent (objectui#10899)', () parent_id: 'c1', body: 'Countersigned copy attached', }), + { onMentionsWithheld: expect.any(Function) }, ); // The reply is not "sent": no reply row, and the parent shows no reply count. expect(feedRow('Countersigned copy attached')).toBeNull(); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.feedRecordScope.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.feedRecordScope.test.tsx index 2c63dbd7b71..e11a109de6d 100644 --- a/ui/packages/app-shell/src/views/RecordDetailView.feedRecordScope.test.tsx +++ b/ui/packages/app-shell/src/views/RecordDetailView.feedRecordScope.test.tsx @@ -450,6 +450,8 @@ describe('RecordDetailView — optimistic comments ride with their own record (# expect(dataSource.create).toHaveBeenCalledWith( 'sys_comment', expect.objectContaining({ thread_id: `${OBJECT_NAME}:${REC_B}`, body: 'written on B' }), + // The per-call listener for the answer's `withheldMentions` (objectstack#28218). + { onMentionsWithheld: expect.any(Function) }, ); rerender(tree(dataSource, REC_A, [AUTHORED_PAGE_WITH_DISCUSSION])); diff --git a/ui/packages/app-shell/src/views/RecordDetailView.withheldMentions-28218.test.tsx b/ui/packages/app-shell/src/views/RecordDetailView.withheldMentions-28218.test.tsx new file mode 100644 index 00000000000..3a4e8a4b41f --- /dev/null +++ b/ui/packages/app-shell/src/views/RecordDetailView.withheldMentions-28218.test.tsx @@ -0,0 +1,299 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The comment author is told, in one sentence, who a mention will not reach + * (objectstack#28218, ruling A on objectstack#27703). + * + * The server withholds the notification of a mentioned user the mention gate + * refuses, and answers their ids to the author's own write as + * `withheldMentions`. The record page's three comment writes (a comment, a + * reply, an edit) hear that member through the data source's per-call + * `onMentionsWithheld` listener, map the ids to names through the mention + * picker's suggestions (the `sys_user` read the composer offers), and raise one + * warning naming them, with no reason given. A write that withheld nobody says + * nothing. + * + * Real subjects: `RecordDetailView` on an authored page composing + * `record:discussion`, driven through its real composer, reply input and edit + * form, under a real `I18nProvider` with the `en` pack read by key. The data + * source double answers the listener the way `ObjectStackAdapter` does. + */ + +import * as React from 'react'; +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, cleanup, fireEvent, within, act, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { builtInLocales } from '@object-ui/i18n/locales'; + +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => ({ user: { id: 'u-ada', name: 'Ada', image: null }, activeOrganization: null }), + createAuthenticatedFetch: () => vi.fn(), +})); + +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + useRecordPresence: () => [], + PresenceAvatars: () => null, +})); + +vi.mock('sonner', () => ({ + toast: Object.assign(vi.fn(), { + success: vi.fn(), + error: vi.fn(), + info: vi.fn(), + warning: vi.fn(), + loading: vi.fn(), + dismiss: vi.fn(), + }), +})); + +vi.mock('./ActionConfirmDialog', () => ({ ActionConfirmDialog: () => null })); +vi.mock('./ActionParamDialog', () => ({ ActionParamDialog: () => null })); +vi.mock('./ActionResultDialog', () => ({ ActionResultDialog: () => null })); +vi.mock('./FlowRunner', () => ({ FlowRunner: () => null })); +vi.mock('./MetadataInspector', () => ({ + MetadataPanel: () => null, + useMetadataInspector: () => ({ showDebug: false, toggle: () => {} }), +})); + +import { toast } from 'sonner'; +import { I18nProvider } from '@object-ui/i18n'; +import { MetadataCtx } from '@object-ui/react'; +import { RecordDetailView } from './RecordDetailView'; + +type Pack = { + detail: { + mentionsWithheld: string; + leaveCommentPlaceholder: string; + submitComment: string; + replyPlaceholder: string; + editComment: string; + save: string; + }; + validation: { formInvalidJoiner: string }; +}; +/** The pack, read by key: the pin is which copy shows and whom it names. */ +const EN = builtInLocales.en as unknown as Pack; +const withheldSentence = (...names: string[]) => + EN.detail.mentionsWithheld.replace('{{names}}', names.join(EN.validation.formInvalidJoiner)); + +const COMMENT = 'sys_comment'; +const OBJECT_NAME = 'crm_case'; +const RECORD_ID = 'case-7'; +const THREAD = `${OBJECT_NAME}:${RECORD_ID}`; + +/** The picker's roster: the `sys_user` read the page offers as suggestions. */ +const USERS = [ + { id: 'u-ada', name: 'Ada' }, + { id: 'u-grace', name: 'Grace Hopper' }, + { id: 'u-linus', name: 'Linus' }, +]; + +const ADA_ROW = { + id: 'c-ada', + thread_id: THREAD, + author_id: 'u-ada', + author_name: 'Ada', + body: 'Customer called back about the outage', + is_edited: false, + created_at: '2026-10-01T08:00:00.000Z', +}; + +const OBJECTS = [ + { + name: OBJECT_NAME, + label: 'Case', + managedBy: 'platform', + fields: { id: { type: 'text', label: 'Id' }, subject: { type: 'text', label: 'Subject' } }, + }, +]; + +const PAGE = { + name: 'case_record_page', + type: 'record', + object: OBJECT_NAME, + regions: [ + { name: 'main', components: [{ type: 'page:header', properties: { title: '{subject}' } }, { type: 'record:discussion' }] }, + ], +}; + +type WriteOpts = { onMentionsWithheld?: (ids: string[]) => void } | undefined; + +/** + * `withheld` is what the server's mention gate refuses: a write that mentions + * any of those ids answers them to the listener, as `ObjectStackAdapter` hands + * the answer's `withheldMentions` on. + */ +function makeDataSource(withheld: string[]) { + const answer = (mentions: unknown, opts: WriteOpts) => { + const named: string[] = JSON.parse(String(mentions ?? '[]')); + const ids = named.filter((id) => withheld.includes(id)); + if (ids.length > 0) opts?.onMentionsWithheld?.(ids); + }; + return { + find: vi.fn(async (objectName: string) => { + if (objectName === COMMENT) return { data: [{ ...ADA_ROW }] }; + if (objectName === 'sys_user') return { data: USERS.map((u) => ({ ...u })) }; + return { data: [] }; + }), + findOne: vi.fn(async (_o: string, id: string) => ({ id, subject: `Case ${id}` })), + create: vi.fn(async (_o: string, data: any, opts?: WriteOpts) => { + answer(data.mentions, opts); + return { ...data }; + }), + update: vi.fn(async (_o: string, id: string, patch: any, opts?: WriteOpts) => { + answer(patch.mentions, opts); + return { ...ADA_ROW, id, ...patch, is_edited: true }; + }), + delete: vi.fn(async () => true), + } as any; +} + +async function mount(dataSource: ReturnType) { + const pages = [PAGE]; + const metadata = { + objects: OBJECTS, + pages, + loading: false, + error: null, + refresh: async () => {}, + invalidate: () => {}, + ensureType: async () => pages, + getItem: async () => null, + getItemsByType: (type: string) => (type === 'page' ? pages : type === 'object' ? OBJECTS : []), + getTypeStatus: () => 'ready', + } as any; + const rendered = render( + + + + {}} + objectNameOverride={OBJECT_NAME} + recordIdOverride={RECORD_ID} + embedded + /> + + + , + ); + const view = within(rendered.container); + await view.findByText(ADA_ROW.body); + // The picker's roster has been read: the suggestions the writes map through. + await waitFor(() => expect(dataSource.find).toHaveBeenCalledWith('sys_user', expect.anything())); + await act(async () => {}); + return view; +} + +type View = Awaited>; + +async function postComment(view: View, text: string) { + const box = await view.findByPlaceholderText(EN.detail.leaveCommentPlaceholder); + fireEvent.change(box, { target: { value: text } }); + await act(async () => { + fireEvent.click(view.getByTitle(EN.detail.submitComment)); + }); +} + +beforeEach(() => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => + new Response(JSON.stringify({ data: [] }), { status: 200, headers: { 'content-type': 'application/json' } }), + ), + ); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.clearAllMocks(); +}); + +describe('the comment author is told who a mention will not reach (objectstack#28218)', () => { + it('a comment: one warning names the withheld users by their picker names, and the comment is posted', async () => { + const dataSource = makeDataSource(['u-grace', 'u-linus']); + const view = await mount(dataSource); + + await postComment(view, 'Please check @Grace Hopper and @Linus before Friday'); + + await waitFor(() => expect(toast.warning).toHaveBeenCalledTimes(1)); + expect(toast.warning).toHaveBeenCalledWith(withheldSentence('Grace Hopper', 'Linus')); + // The write sent every mention; the server decides who is told. + const [, row] = dataSource.create.mock.calls[0]; + expect(JSON.parse(row.mentions)).toEqual(['u-grace', 'u-linus']); + expect( + await view.findByText('Please check @Grace Hopper and @Linus before Friday', { selector: ':not(textarea):not(input)' }), + ).toBeInTheDocument(); + expect(toast.error).not.toHaveBeenCalled(); + }); + + it('only the withheld users are named: a mention the server will deliver is not in the sentence', async () => { + const dataSource = makeDataSource(['u-linus']); + const view = await mount(dataSource); + + await postComment(view, '@Grace Hopper and @Linus, see above'); + + await waitFor(() => expect(toast.warning).toHaveBeenCalledTimes(1)); + expect(toast.warning).toHaveBeenCalledWith(withheldSentence('Linus')); + }); + + it('control: a comment whose mentions all reach their users says nothing', async () => { + const dataSource = makeDataSource([]); + const view = await mount(dataSource); + + await postComment(view, 'Thanks @Grace Hopper'); + + expect( + await view.findByText('Thanks @Grace Hopper', { selector: ':not(textarea):not(input)' }), + ).toBeInTheDocument(); + expect(dataSource.create).toHaveBeenCalledTimes(1); + expect(toast.warning).not.toHaveBeenCalled(); + }); + + it('a reply names the withheld users the same way', async () => { + const dataSource = makeDataSource(['u-grace']); + const view = await mount(dataSource); + + const input = await view.findByPlaceholderText(EN.detail.replyPlaceholder); + fireEvent.change(input, { target: { value: '@Grace Hopper can you confirm?' } }); + await act(async () => { + fireEvent.click(view.getByRole('button', { name: 'Send reply' })); + }); + + await waitFor(() => expect(toast.warning).toHaveBeenCalledTimes(1)); + expect(toast.warning).toHaveBeenCalledWith(withheldSentence('Grace Hopper')); + expect(dataSource.create).toHaveBeenCalledWith( + COMMENT, + expect.objectContaining({ parent_id: 'c-ada', mentions: JSON.stringify(['u-grace']) }), + { onMentionsWithheld: expect.any(Function) }, + ); + }); + + it('an edit that adds a mention names the withheld user the same way', async () => { + const dataSource = makeDataSource(['u-linus']); + const view = await mount(dataSource); + + fireEvent.click(view.getByRole('button', { name: EN.detail.editComment })); + fireEvent.change(view.getByRole('textbox', { name: EN.detail.editComment }), { + target: { value: 'Customer called back about the outage, @Linus FYI' }, + }); + await act(async () => { + fireEvent.click(view.getByRole('button', { name: EN.detail.save })); + }); + + await waitFor(() => expect(toast.warning).toHaveBeenCalledTimes(1)); + expect(toast.warning).toHaveBeenCalledWith(withheldSentence('Linus')); + expect(dataSource.update).toHaveBeenCalledWith( + COMMENT, + 'c-ada', + { body: 'Customer called back about the outage, @Linus FYI', mentions: JSON.stringify(['u-linus']) }, + { onMentionsWithheld: expect.any(Function) }, + ); + expect(toast.error).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/packages/data-objectstack/src/withheldMentions-28218.test.ts b/ui/packages/data-objectstack/src/withheldMentions-28218.test.ts new file mode 100644 index 00000000000..c93ba856992 --- /dev/null +++ b/ui/packages/data-objectstack/src/withheldMentions-28218.test.ts @@ -0,0 +1,104 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * A `sys_comment` write answers the ids of the mentioned users the server will + * not notify as `withheldMentions` (objectstack#27703), on the author's own + * answer only. `create` / `update` resolve the record, so the answer's other + * members never reached a caller; the per-call `onMentionsWithheld` listener + * is the door to this one (objectstack#28218), and what every caller resolves + * is unchanged. + */ +import { describe, it, expect, vi } from 'vitest'; +import { ObjectStackAdapter } from './index'; + +function makeDS(stub: Record) { + const ds: any = new ObjectStackAdapter({ + baseUrl: 'http://test.local', + fetch: vi.fn(async () => + new Response(JSON.stringify({ success: true, data: { capabilities: {}, routes: {} } }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }), + ), + }); + ds.connected = true; + ds.connectionState = 'connected'; + ds.client = { data: stub }; + return ds; +} + +describe('ObjectStackAdapter create/update hand `withheldMentions` to the per-call listener (objectstack#28218)', () => { + it('create: the listener hears the ids once, and the call still resolves the plain record', async () => { + const create = vi.fn().mockResolvedValue({ + object: 'sys_comment', + id: 'c1', + record: { id: 'c1', body: 'hi @Ada @Grace' }, + withheldMentions: ['u_ada', 'u_grace'], + }); + const ds = makeDS({ create }); + const onMentionsWithheld = vi.fn(); + + const rec = await ds.create('sys_comment', { body: 'hi @Ada @Grace' }, { onMentionsWithheld }); + + expect(rec).toEqual({ id: 'c1', body: 'hi @Ada @Grace' }); + expect(onMentionsWithheld).toHaveBeenCalledTimes(1); + expect(onMentionsWithheld).toHaveBeenCalledWith(['u_ada', 'u_grace']); + // The listener is the adapter's, never forwarded to the client call. + expect(create).toHaveBeenCalledWith('sys_comment', { body: 'hi @Ada @Grace' }); + }); + + it('update: the listener hears the ids, and `ifMatch` still reaches the client alone', async () => { + const update = vi.fn().mockResolvedValue({ + object: 'sys_comment', + id: 'c1', + record: { id: 'c1', is_edited: true }, + withheldMentions: ['u_grace'], + }); + const ds = makeDS({ update }); + const onMentionsWithheld = vi.fn(); + + const rec = await ds.update('sys_comment', 'c1', { body: 'now @Grace' }, { ifMatch: 't1', onMentionsWithheld }); + + expect(rec).toEqual({ id: 'c1', is_edited: true }); + expect(onMentionsWithheld).toHaveBeenCalledWith(['u_grace']); + expect(update).toHaveBeenCalledWith('sys_comment', 'c1', { body: 'now @Grace' }, { ifMatch: 't1' }); + + // Without `ifMatch` the client is called exactly as before: no options bag. + await ds.update('sys_comment', 'c1', { body: 'again' }, { onMentionsWithheld }); + expect(update).toHaveBeenLastCalledWith('sys_comment', 'c1', { body: 'again' }, undefined); + }); + + it('control: an answer without the key, with none withheld, or not a list of ids calls nothing', async () => { + const create = vi + .fn() + .mockResolvedValueOnce({ record: { id: 'c1' } }) + .mockResolvedValueOnce({ record: { id: 'c2' }, withheldMentions: [] }) + .mockResolvedValueOnce({ record: { id: 'c3' }, withheldMentions: 'u_ada' }) + .mockResolvedValueOnce({ record: { id: 'c4' }, withheldMentions: [7, '', null] }); + const ds = makeDS({ create }); + const onMentionsWithheld = vi.fn(); + + for (let i = 0; i < 4; i++) await ds.create('sys_comment', { body: 'x' }, { onMentionsWithheld }); + + expect(onMentionsWithheld).not.toHaveBeenCalled(); + }); + + it('a write without the listener resolves as before, and a throwing listener does not fail a stored write', async () => { + const create = vi.fn().mockResolvedValue({ record: { id: 'c1' }, withheldMentions: ['u_ada'] }); + const ds = makeDS({ create }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + await expect(ds.create('sys_comment', { body: 'x' })).resolves.toEqual({ id: 'c1' }); + await expect( + ds.create('sys_comment', { body: 'x' }, { onMentionsWithheld: () => { throw new Error('boom'); } }), + ).resolves.toEqual({ id: 'c1' }); + expect(warn).toHaveBeenCalledWith('ObjectStackAdapter: withheld-mentions listener error', expect.any(Error)); + warn.mockRestore(); + }); +}); diff --git a/ui/packages/plugin-detail/src/__tests__/RichTextCommentInput.mentionChips-28218.test.tsx b/ui/packages/plugin-detail/src/__tests__/RichTextCommentInput.mentionChips-28218.test.tsx new file mode 100644 index 00000000000..c9c063a90df --- /dev/null +++ b/ui/packages/plugin-detail/src/__tests__/RichTextCommentInput.mentionChips-28218.test.tsx @@ -0,0 +1,83 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The composer draws a picked mention as a chip (objectstack#28218, the + * presentation half of ruling A on objectstack#27703): a mention used to look + * like plain typed text, so the author could not see whom the comment names. + * + * A mention is "picked" when the text holds `@LABEL` of a suggestion, resolved + * exactly as the write resolves it (`extractMentions`): those are the users the + * comment's `mentions` will carry. Under the editor they are a labelled row of + * chips; in the preview each `@LABEL` is a chip, a multi-word label whole. Any + * other `@word` keeps the plain highlight. + */ + +import * as React from 'react'; +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup, within } from '@testing-library/react'; +import { RichTextCommentInput, type MentionSuggestion } from '../RichTextCommentInput'; +import { DETAIL_DEFAULT_TRANSLATIONS } from '../useDetailTranslation'; + +const MENTIONED = DETAIL_DEFAULT_TRANSLATIONS['detail.mentionedPeople']; +const PREVIEW = DETAIL_DEFAULT_TRANSLATIONS['detail.preview']; + +const SUGGESTIONS: MentionSuggestion[] = [ + { id: 'u-grace', label: 'Grace Hopper' }, + { id: 'u-linus', label: 'Linus' }, + { id: 'u-ann', label: 'Ann' }, +]; + +function Composer({ initial }: { initial: string }) { + const [value, setValue] = React.useState(initial); + return ; +} + +afterEach(() => cleanup()); + +describe('RichTextCommentInput draws picked mentions as chips (objectstack#28218)', () => { + it('a row of chips under the editor names each picked user once, whole multi-word labels included', () => { + render(); + + const row = screen.getByRole('list', { name: MENTIONED }); + const chips = within(row).getAllByRole('listitem').map((li) => li.textContent); + expect(chips).toEqual(['@Grace Hopper', '@Linus']); + }); + + it('the row follows the text: a typed mention adds a chip, a removed one drops it, none draws no row', () => { + render(); + expect(screen.queryByRole('list', { name: MENTIONED })).toBeNull(); + + const box = screen.getByRole('textbox'); + fireEvent.change(box, { target: { value: 'ping @Linus' } }); + expect(within(screen.getByRole('list', { name: MENTIONED })).getAllByRole('listitem').map((li) => li.textContent)) + .toEqual(['@Linus']); + + fireEvent.change(box, { target: { value: 'ping nobody' } }); + expect(screen.queryByRole('list', { name: MENTIONED })).toBeNull(); + }); + + it('a prefix of a longer name, or an @word naming nobody, is not a chip', () => { + render(); + expect(screen.queryByRole('list', { name: MENTIONED })).toBeNull(); + }); + + it('the preview draws each picked @LABEL as one chip and keeps any other @word a plain highlight', () => { + const { container } = render(); + fireEvent.click(screen.getByTitle(PREVIEW)); + + const chips = Array.from(container.querySelectorAll('[data-mention-chip]')).map((el) => el.textContent); + expect(chips).toEqual(['@Grace Hopper']); + expect(screen.getByText('@nobody')).toHaveClass('text-primary'); + expect(screen.getByText('@nobody')).not.toHaveAttribute('data-mention-chip'); + expect(container.querySelector('strong')).toHaveTextContent('Note'); + }); + + it('the preview escapes the text, chips included', () => { + const { container } = render(); + fireEvent.click(screen.getByTitle(PREVIEW)); + expect(container.querySelector('b')).toBeNull(); + expect(container.textContent).toContain('x'); + expect(container.querySelector('[data-mention-chip]')).toHaveTextContent('@Linus'); + }); +}); From fcdb4c489d303923ce559a36b4ed6574b0dd0dff Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 20:00:38 +0000 Subject: [PATCH 3/3] docs(data-objectstack): name the per-call withheld-mentions listener; changeset Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01L9qibvjxjPWDhWVKzkEU2x --- .changeset/28218-withheld-mentions-console.md | 17 +++++++++++++++++ ui/packages/data-objectstack/README.md | 5 +++-- 2 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 .changeset/28218-withheld-mentions-console.md diff --git a/.changeset/28218-withheld-mentions-console.md b/.changeset/28218-withheld-mentions-console.md new file mode 100644 index 00000000000..c81c676d3f9 --- /dev/null +++ b/.changeset/28218-withheld-mentions-console.md @@ -0,0 +1,17 @@ +--- +'@object-ui/data-objectstack': patch +'@object-ui/app-shell': patch +'@object-ui/plugin-detail': patch +'@object-ui/i18n': patch +--- + +feat(console): a comment author is told, in one sentence, which mentioned users will not be notified + +Clause-②: yes (widening) +Metadata-protocol: none + +When a record comment @mentions someone the server's mention gate refuses, the server saves the comment with every mention, withholds that person's notification, and answers their ids to the author's own write as `withheldMentions`. The Console said nothing, so the author believed the colleague had been told. Now: + +- **One sentence, no reason.** Posting a comment, posting a reply, or saving an edit that adds a mention raises one warning naming the users who will not be notified, by the names the mention picker offered ("Grace Hopper, Linus will not be notified of this comment."). It gives no reason. A write that withheld nobody says nothing. The copy is `detail.mentionsWithheld` in all ten packs. +- **The answer reaches the caller.** `ObjectStackAdapter.create` and `.update` take an optional per-call `onMentionsWithheld(ids)` listener (on `update`, beside `ifMatch`). It is called once, after the write succeeded, only when the answer names at least one id; a throwing listener does not fail the write. Both methods still resolve the record, so no other caller sees a change. +- **Mention chips.** The comment composer draws the users the text mentions as a row of chips under the editor (labelled `detail.mentionedPeople`), resolved the way the write resolves them, and the preview draws each picked `@name` as one chip, multi-word names whole. diff --git a/ui/packages/data-objectstack/README.md b/ui/packages/data-objectstack/README.md index 7dbde7626fb..abde40e1bb1 100644 --- a/ui/packages/data-objectstack/README.md +++ b/ui/packages/data-objectstack/README.md @@ -1119,8 +1119,9 @@ new ObjectStackAdapter(config: { - `connect()` - Establish connection to ObjectStack server - `find(resource, params?)` - Query multiple records - `findOne(resource, id, params?)` - Get a single record by ID -- `create(resource, data)` - Create a new record -- `update(resource, id, data)` - Update an existing record +- `create(resource, data, opts?)` - Create a new record +- `update(resource, id, data, opts?)` - Update an existing record; `opts.ifMatch` sends `If-Match` +- `opts.onMentionsWithheld(ids)` on either - Hear a `sys_comment` write's `withheldMentions`: the ids of the mentioned users the server will not notify, on the author's own write only. Called once after the write succeeded, only when at least one id is named; the call still resolves the record - `delete(resource, id)` - Delete a record - `bulk(resource, operation, data)` - Batch operations (create/update/delete) - `bulkUpdate(resource, ids, patch)` / `bulkDelete(resource, ids)` - One `updateMany` / `deleteMany` request for many ids (`continueOnError: true`). Resolves to `{ succeeded, errors }`: the server's count plus one `{ id, error }` per refused record, with the server's reason