Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/28218-withheld-mentions-console.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]));
Expand Down
34 changes: 28 additions & 6 deletions ui/packages/app-shell/src/views/RecordDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
Expand All @@ -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(
Expand Down Expand Up @@ -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);
}
Expand All @@ -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],
);

/**
Expand Down Expand Up @@ -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);
}
Expand All @@ -3107,7 +3129,7 @@ export function RecordDetailView({ dataSource, objects, onEdit, objectNameOverri
),
}));
},
[dataSource, feedRecordKey, feedItemsByRecord, mentionSuggestions, t],
[dataSource, feedRecordKey, feedItemsByRecord, mentionSuggestions, t, tellMentionsWithheld],
);

/**
Expand Down
Loading
Loading