Skip to content

Commit 5e05c59

Browse files
committed
feat(web): parse pasted @handle mentions into composer tokens
1 parent f22b501 commit 5e05c59

1 file changed

Lines changed: 103 additions & 7 deletions

File tree

web/static/app.js

Lines changed: 103 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1067,6 +1067,22 @@ function App() {
10671067
.filter((user) => user.handle.toLowerCase().includes(composerMentionState.query.toLowerCase()) || user.name.toLowerCase().includes(composerMentionState.query.toLowerCase()))
10681068
.slice(0, 5);
10691069
}, [data, activeConversation, composerMentionState]);
1070+
const mentionableUsersByHandle = useMemo(() => {
1071+
const result = new Map();
1072+
if (!data) {
1073+
return result;
1074+
}
1075+
const allowed = new Set(activeConversation?.members ?? []);
1076+
data.users
1077+
.filter((user) => allowed.has(user.id))
1078+
.forEach((user) => {
1079+
const handle = String(user.handle ?? "").trim().toLowerCase();
1080+
if (handle && !result.has(handle)) {
1081+
result.set(handle, user);
1082+
}
1083+
});
1084+
return result;
1085+
}, [data, activeConversation]);
10701086

10711087
const draftSegments = useMemo(
10721088
() => draftsByConversationId[activeConversationId] ?? [],
@@ -2856,7 +2872,13 @@ function App() {
28562872
onKeyUp=${syncComposerFromEditor}
28572873
onPaste=${(event) => {
28582874
event.preventDefault();
2859-
insertPlainTextAtSelection(event.clipboardData?.getData("text/plain") ?? "");
2875+
const pasted = event.clipboardData?.getData("text/plain") ?? "";
2876+
const segments = normalizeTextMentions([{ type: "text", text: pasted }], mentionableUsersByHandle);
2877+
if (segments.some((segment) => segment.type === "mention")) {
2878+
insertComposerSegmentsAtSelection(segments);
2879+
} else {
2880+
insertPlainTextAtSelection(pasted);
2881+
}
28602882
syncComposerFromEditor();
28612883
}}
28622884
/>
@@ -4555,17 +4577,16 @@ function createMentionTokenElement(user) {
45554577
return token;
45564578
}
45574579

4558-
function renderComposerSegments(root, segments) {
4559-
if (!root) {
4580+
function appendComposerSegments(parent, segments) {
4581+
if (!parent) {
45604582
return;
45614583
}
4562-
root.replaceChildren();
45634584
for (const segment of segments ?? []) {
45644585
if (!segment) {
45654586
continue;
45664587
}
45674588
if (segment.type === "mention") {
4568-
root.append(createMentionTokenElement({
4589+
parent.append(createMentionTokenElement({
45694590
id: segment.userId,
45704591
name: segment.userName,
45714592
handle: segment.userName,
@@ -4575,15 +4596,23 @@ function renderComposerSegments(root, segments) {
45754596
const parts = String(segment.text ?? "").split("\n");
45764597
parts.forEach((part, index) => {
45774598
if (part) {
4578-
root.append(document.createTextNode(part));
4599+
parent.append(document.createTextNode(part));
45794600
}
45804601
if (index < parts.length - 1) {
4581-
root.append(document.createElement("br"));
4602+
parent.append(document.createElement("br"));
45824603
}
45834604
});
45844605
}
45854606
}
45864607

4608+
function renderComposerSegments(root, segments) {
4609+
if (!root) {
4610+
return;
4611+
}
4612+
root.replaceChildren();
4613+
appendComposerSegments(root, segments);
4614+
}
4615+
45874616
function parseComposerSegments(root) {
45884617
if (!root) {
45894618
return [];
@@ -4707,6 +4736,54 @@ function serializeComposerSegments(segments) {
47074736
}).join("");
47084737
}
47094738

4739+
function splitTextSegmentByMentions(text, mentionableUsersByHandle) {
4740+
const content = String(text ?? "");
4741+
if (!content || !mentionableUsersByHandle || mentionableUsersByHandle.size === 0) {
4742+
return content ? [{ type: "text", text: content }] : [];
4743+
}
4744+
const mentionPattern = /(^|[^\w])@([a-zA-Z0-9._-]+)/g;
4745+
const segments = [];
4746+
let lastIndex = 0;
4747+
let match;
4748+
while ((match = mentionPattern.exec(content)) !== null) {
4749+
const prefix = match[1] ?? "";
4750+
const handle = match[2] ?? "";
4751+
const user = mentionableUsersByHandle.get(handle.toLowerCase());
4752+
if (!user) {
4753+
continue;
4754+
}
4755+
const mentionStart = match.index + prefix.length;
4756+
if (mentionStart > lastIndex) {
4757+
segments.push({ type: "text", text: content.slice(lastIndex, mentionStart) });
4758+
}
4759+
segments.push({
4760+
type: "mention",
4761+
userId: user.id,
4762+
userName: user.name || user.handle || user.id,
4763+
});
4764+
lastIndex = mentionStart + handle.length + 1;
4765+
}
4766+
if (lastIndex < content.length) {
4767+
segments.push({ type: "text", text: content.slice(lastIndex) });
4768+
}
4769+
return segments;
4770+
}
4771+
4772+
function normalizeTextMentions(segments, mentionableUsersByHandle) {
4773+
const normalized = [];
4774+
for (const segment of segments ?? []) {
4775+
if (!segment) {
4776+
continue;
4777+
}
4778+
if (segment.type === "mention") {
4779+
normalized.push(segment);
4780+
continue;
4781+
}
4782+
normalized.push(...splitTextSegmentByMentions(segment.text ?? "", mentionableUsersByHandle));
4783+
}
4784+
return normalizeComposerSegments(normalized);
4785+
}
4786+
47104787
function getComposerMentionState(root) {
47114788
if (!root) {
47124789
return null;
@@ -4822,6 +4899,25 @@ function insertPlainTextAtSelection(text) {
48224899
selection.addRange(nextRange);
48234900
}
48244901

4902+
function insertComposerSegmentsAtSelection(segments) {
4903+
const selection = window.getSelection();
4904+
if (!selection || selection.rangeCount === 0) {
4905+
return;
4906+
}
4907+
const range = selection.getRangeAt(0);
4908+
range.deleteContents();
4909+
const marker = document.createTextNode("");
4910+
const fragment = document.createDocumentFragment();
4911+
appendComposerSegments(fragment, segments);
4912+
fragment.append(marker);
4913+
range.insertNode(fragment);
4914+
const nextRange = document.createRange();
4915+
nextRange.setStart(marker, 0);
4916+
nextRange.collapse(true);
4917+
selection.removeAllRanges();
4918+
selection.addRange(nextRange);
4919+
}
4920+
48254921
function removeAdjacentMentionToken(root, direction) {
48264922
if (!root) {
48274923
return false;

0 commit comments

Comments
 (0)