From 46d71959285e38d7f71ff624e8ef7e9f7ffb91ff Mon Sep 17 00:00:00 2001 From: Leendert de Borst Date: Fri, 30 Jan 2026 00:04:31 +0100 Subject: [PATCH] Update notes rendering --- .../src/entrypoints/contentScript/Popup.ts | 130 ++++++++++++------ .../components/Items/Details/FieldBlock.tsx | 77 +++++++++-- .../Credentials/FormattedNote.razor | 5 +- 3 files changed, 157 insertions(+), 55 deletions(-) diff --git a/apps/browser-extension/src/entrypoints/contentScript/Popup.ts b/apps/browser-extension/src/entrypoints/contentScript/Popup.ts index 167b19bf2..2b2da1d76 100644 --- a/apps/browser-extension/src/entrypoints/contentScript/Popup.ts +++ b/apps/browser-extension/src/entrypoints/contentScript/Popup.ts @@ -31,6 +31,41 @@ let popupListeners = new WeakMap(); */ const clickValidator = ClickValidator.getInstance(); +/** + * Create a suggestion pill element using safe DOM methods. + */ +const createSuggestionPill = (value: string): HTMLElement => { + const pill = document.createElement('span'); + pill.className = 'av-suggestion-pill'; + + const textSpan = document.createElement('span'); + textSpan.className = 'av-suggestion-pill-text'; + textSpan.dataset.value = value; + textSpan.textContent = value; + + const deleteSpan = document.createElement('span'); + deleteSpan.className = 'av-suggestion-pill-delete'; + deleteSpan.dataset.value = value; + deleteSpan.title = 'Remove'; + deleteSpan.textContent = '×'; + + pill.appendChild(textSpan); + pill.appendChild(deleteSpan); + + return pill; +}; + +/** + * Create a suggested name element using safe DOM methods. + */ +const createSuggestedNameSpan = (name: string): HTMLElement => { + const span = document.createElement('span'); + span.className = 'av-suggested-name'; + span.dataset.name = name; + span.textContent = name; + return span; +}; + /** * Open (or refresh) the autofill popup including check if vault is locked. */ @@ -865,8 +900,6 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon const passwordLengthText = await t('items.passwordLength'); const changePasswordComplexityText = await t('items.changePasswordComplexity'); - const suggestedNamesHtml = await getSuggestedNamesHtml(suggestedNames, suggestedNames[0] ?? ''); - // Create the main content popup.innerHTML = `
@@ -925,7 +958,7 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon class="av-create-popup-input" placeholder="${enterServiceNameText}" > - ${suggestedNames.length > 1 ? `
${suggestedNamesHtml}
` : ''} + ${suggestedNames.length > 1 ? '
' : ''}
@@ -1029,6 +1062,12 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon const emailSuggestions = popup.querySelector('#email-suggestions') as HTMLElement; const usernameSuggestions = popup.querySelector('#username-suggestions') as HTMLElement; + // Populate suggested names + const suggestedNamesContainer = popup.querySelector('.av-suggested-names') as HTMLElement; + if (suggestedNamesContainer) { + await populateSuggestedNames(suggestedNamesContainer, suggestedNames, suggestedNames[0] ?? ''); + } + /** * Update history with new value (max 2 unique entries) */ @@ -1063,40 +1102,38 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon }; /** - * Format suggestions HTML as pill-style buttons + * Update suggestions display using safe DOM methods. */ - const formatSuggestionsHtml = async (history: string[], currentValue: string): Promise => { + const updateSuggestions = (input: HTMLInputElement, suggestionsContainer: HTMLElement, history: string[]): void => { + const currentValue = input.value.trim(); + // Filter out the current value from history and limit to 2 items const filteredHistory = history .filter(item => item.toLowerCase() !== currentValue.toLowerCase()) .slice(0, 2); + // Clear existing content + suggestionsContainer.textContent = ''; + if (filteredHistory.length === 0) { - return ''; + suggestionsContainer.style.display = 'none'; + return; } - // Build HTML with pill-style buttons - return filteredHistory.map(item => - ` - ${item} - × - ` - ).join(' '); - }; + // Build pill elements + filteredHistory.forEach((item, index) => { + if (index > 0) { + suggestionsContainer.appendChild(document.createTextNode(' ')); + } + suggestionsContainer.appendChild(createSuggestionPill(item)); + }); - /** - * Update suggestions display - */ - const updateSuggestions = async (input: HTMLInputElement, suggestionsContainer: HTMLElement, history: string[]): Promise => { - const currentValue = input.value.trim(); - const html = await formatSuggestionsHtml(history, currentValue); - suggestionsContainer.innerHTML = html; - suggestionsContainer.style.display = html ? 'flex' : 'none'; + suggestionsContainer.style.display = 'flex'; }; // Initial display of suggestions - await updateSuggestions(customEmail, emailSuggestions, emailHistory); - await updateSuggestions(customUsername, usernameSuggestions, usernameHistory); + updateSuggestions(customEmail, emailSuggestions, emailHistory); + updateSuggestions(customUsername, usernameSuggestions, usernameHistory); // Handle popout button click popoutBtn.addEventListener('click', (e) => { @@ -1112,13 +1149,13 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon }); // Handle email input - customEmail.addEventListener('input', async () => { - await updateSuggestions(customEmail, emailSuggestions, emailHistory); + customEmail.addEventListener('input', () => { + updateSuggestions(customEmail, emailSuggestions, emailHistory); }); // Handle username input - customUsername.addEventListener('input', async () => { - await updateSuggestions(customUsername, usernameSuggestions, usernameHistory); + customUsername.addEventListener('input', () => { + updateSuggestions(customUsername, usernameSuggestions, usernameHistory); }); // Handle suggestion clicks for email @@ -1133,7 +1170,7 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon if (value) { const updatedHistory = await removeFromHistory(value, CUSTOM_EMAIL_HISTORY_KEY); emailHistory.splice(0, emailHistory.length, ...updatedHistory); - await updateSuggestions(customEmail, emailSuggestions, emailHistory); + updateSuggestions(customEmail, emailSuggestions, emailHistory); } } else { // Check if pill or pill text was clicked @@ -1143,7 +1180,7 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon const value = textElement?.dataset.value; if (value) { customEmail.value = value; - await updateSuggestions(customEmail, emailSuggestions, emailHistory); + updateSuggestions(customEmail, emailSuggestions, emailHistory); } } } @@ -1161,7 +1198,7 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon if (value) { const updatedHistory = await removeFromHistory(value, CUSTOM_USERNAME_HISTORY_KEY); usernameHistory.splice(0, usernameHistory.length, ...updatedHistory); - await updateSuggestions(customUsername, usernameSuggestions, usernameHistory); + updateSuggestions(customUsername, usernameSuggestions, usernameHistory); } } else { // Check if pill or pill text was clicked @@ -1171,7 +1208,7 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon const value = textElement?.dataset.value; if (value) { customUsername.value = value; - await updateSuggestions(customUsername, usernameSuggestions, usernameHistory); + updateSuggestions(customUsername, usernameSuggestions, usernameHistory); } } } @@ -1671,10 +1708,9 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon customUsername.value = name; // Update the suggested names section - const suggestedNamesContainer = target.closest('.av-suggested-names'); + const suggestedNamesContainer = target.closest('.av-suggested-names') as HTMLElement; if (suggestedNamesContainer) { - // Update the suggestions HTML using the helper function - suggestedNamesContainer.innerHTML = await getSuggestedNamesHtml(suggestedNames, name); + await populateSuggestedNames(suggestedNamesContainer, suggestedNames, name); } } } @@ -1689,21 +1725,33 @@ export async function createAliasCreationPopup(suggestedNames: string[], rootCon } /** - * Get suggested names HTML with current input value excluded + * Populate a suggested names container using safe DOM methods. */ -async function getSuggestedNamesHtml(suggestedNames: string[], currentValue: string): Promise { +async function populateSuggestedNames(container: HTMLElement, suggestedNames: string[], currentValue: string): Promise { // Filter out the current value and create unique set of remaining suggestions const filteredSuggestions = [...new Set(suggestedNames.filter(n => n !== currentValue))]; + // Clear existing content + container.textContent = ''; + if (filteredSuggestions.length === 0) { - return ''; + return; } const orLabel = await t('content.or'); - return `${orLabel} ${filteredSuggestions.map((name, index) => - `${name}${index < filteredSuggestions.length - 1 ? ', ' : ''}` - ).join('')}?`; + // Add "or" label as text node + container.appendChild(document.createTextNode(orLabel + ' ')); + + // Add each suggestion + filteredSuggestions.forEach((name, index) => { + container.appendChild(createSuggestedNameSpan(name)); + if (index < filteredSuggestions.length - 1) { + container.appendChild(document.createTextNode(', ')); + } + }); + + container.appendChild(document.createTextNode('?')); } /** diff --git a/apps/browser-extension/src/entrypoints/popup/components/Items/Details/FieldBlock.tsx b/apps/browser-extension/src/entrypoints/popup/components/Items/Details/FieldBlock.tsx index 765752c8a..ae4a4c9a9 100644 --- a/apps/browser-extension/src/entrypoints/popup/components/Items/Details/FieldBlock.tsx +++ b/apps/browser-extension/src/entrypoints/popup/components/Items/Details/FieldBlock.tsx @@ -16,16 +16,68 @@ type FieldBlockProps = { hideLabel?: boolean; } -/** - * Convert URLs in text to clickable links (same as NotesBlock). - */ -const convertUrlsToLinks = (text: string): string => { - const urlPattern = /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/g; +/** URL pattern for detecting links in text */ +const URL_PATTERN = /(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})/g; - return text.replace(urlPattern, (url) => { +/** + * Split text into parts, separating URLs from regular text. + */ +const splitTextAndUrls = (text: string): { type: 'text' | 'url'; content: string; href?: string }[] => { + const parts: { type: 'text' | 'url'; content: string; href?: string }[] = []; + let lastIndex = 0; + let match; + + // Reset regex state + URL_PATTERN.lastIndex = 0; + + while ((match = URL_PATTERN.exec(text)) !== null) { + // Add text before the URL + if (match.index > lastIndex) { + parts.push({ type: 'text', content: text.slice(lastIndex, match.index) }); + } + + // Add the URL + const url = match[0]; const href = url.startsWith('http') ? url : `http://${url}`; - return `${url}`; - }); + parts.push({ type: 'url', content: url, href }); + + lastIndex = match.index + url.length; + } + + // Add remaining text + if (lastIndex < text.length) { + parts.push({ type: 'text', content: text.slice(lastIndex) }); + } + + return parts; +}; + +/** + * Render text with clickable links. + */ +const TextWithLinks: React.FC<{ text: string }> = ({ text }) => { + const parts = splitTextAndUrls(text); + + return ( + <> + {parts.map((part, index) => { + if (part.type === 'url') { + return ( + + {part.content} + + ); + } + return {part.content}; + })} + + ); }; /** @@ -152,7 +204,7 @@ const FieldBlock: React.FC = ({ field, itemId, hideLabel = fals ); case FieldTypes.TextArea: - // Use NotesBlock-style rendering for multi-line text + // Use safe React rendering for multi-line text with clickable links return (
{!hideLabel && ( @@ -161,10 +213,9 @@ const FieldBlock: React.FC = ({ field, itemId, hideLabel = fals )}
-

+

+ +

); diff --git a/apps/server/AliasVault.Client/Main/Components/Credentials/FormattedNote.razor b/apps/server/AliasVault.Client/Main/Components/Credentials/FormattedNote.razor index 4da37d428..b948a55ff 100644 --- a/apps/server/AliasVault.Client/Main/Components/Credentials/FormattedNote.razor +++ b/apps/server/AliasVault.Client/Main/Components/Credentials/FormattedNote.razor @@ -20,8 +20,11 @@ private static string ConvertUrlsToLinks(string text) { + // HTML-encode the text before processing URLs + var encodedText = System.Web.HttpUtility.HtmlEncode(text); + string urlPattern = @"(https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|www\.[a-zA-Z0-9][a-zA-Z0-9-]+[a-zA-Z0-9]\.[^\s]{2,}|https?:\/\/(?:www\.|(?!www))[a-zA-Z0-9]+\.[^\s]{2,}|www\.[a-zA-Z0-9]+\.[^\s]{2,})"; - return Regex.Replace(text, urlPattern, match => + return Regex.Replace(encodedText, urlPattern, match => { string url = match.Value; if (!url.StartsWith("http://") && !url.StartsWith("https://"))