-
Notifications
You must be signed in to change notification settings - Fork 203
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Create helper functions to manipulate mentions
- Loading branch information
Showing
5 changed files
with
289 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,117 @@ | ||
import type { Mention } from '../../types/api'; | ||
import { buildAccountID } from './account-id'; | ||
|
||
/** | ||
* Wrap all occurrences of @mentions in provided text into the corresponding | ||
* special tag, as long as they are surrounded by "empty" space (space, tab, new | ||
* line, or beginning/end of the whole text). | ||
* | ||
* For example: `@someuser` with the `hypothes.is` authority would become | ||
* `<a data-hyp-mention data-userid="acct:[email protected]">@someuser</a>` | ||
*/ | ||
export function wrapMentions(text: string, authority: string): string { | ||
return text.replace( | ||
// Capture both the potential empty character before the mention (space, tab | ||
// or new line), and the term following the `@` character. | ||
// When we build the mention tag, we need to prepend that empty character to | ||
// avoid altering the spacing and structure of the text. | ||
/(^|\s)@(\w+)(?=\s|$)/g, | ||
(match, precedingWhitespace, username) => { | ||
const tag = document.createElement('a'); | ||
|
||
tag.setAttribute('data-hyp-mention', ''); | ||
tag.setAttribute('data-userid', buildAccountID(username, authority)); | ||
tag.textContent = `@${username}`; | ||
|
||
return `${precedingWhitespace}${tag.outerHTML}`; | ||
}, | ||
); | ||
} | ||
|
||
/** | ||
* Replace all mentions wrapped in the special `<a data-hyp-mention />` tag with | ||
* their plain-text representation. | ||
*/ | ||
export function unwrapMentions(text: string) { | ||
const tmp = document.createElement('div'); | ||
tmp.innerHTML = text; | ||
for (const node of tmp.querySelectorAll('a[data-hyp-mention]')) { | ||
node.replaceWith(node.textContent ?? ''); | ||
} | ||
return tmp.innerHTML; | ||
} | ||
|
||
/** | ||
* A username for a mention that the backend "discarded" as invalid. Possible | ||
* reasons are: the user does not exist, belongs to a different group, is | ||
* nipsa'd, etc. | ||
*/ | ||
export type InvalidUsername = string; | ||
|
||
function elementForMention( | ||
mentionLink: HTMLElement, | ||
mention?: Mention, | ||
): [HTMLElement, Mention | InvalidUsername] { | ||
// If the mention exists in the list of mentions, render it as a link | ||
if (mention && mention.link) { | ||
mentionLink.setAttribute('href', mention.link ?? ''); | ||
mentionLink.setAttribute('target', '_blank'); | ||
mentionLink.classList.add('font-bold'); | ||
|
||
return [mentionLink, mention]; | ||
} | ||
|
||
const username = mentionLink.textContent ?? ''; | ||
|
||
// If the mention doesn't exist, render a "plain text" element | ||
if (!mention) { | ||
const invalidMention = document.createElement('span'); | ||
|
||
invalidMention.textContent = username; | ||
invalidMention.style.fontStyle = 'italic'; | ||
invalidMention.style.borderBottom = 'dotted'; | ||
mentionLink.replaceWith(invalidMention); | ||
|
||
return [invalidMention, username]; | ||
} | ||
|
||
// If the mention exists but has no link, render a "highlighted" element which | ||
// is not a link | ||
const nonLinkMention = document.createElement('span'); | ||
|
||
nonLinkMention.setAttribute('data-hyp-mention', ''); | ||
nonLinkMention.setAttribute('data-userid', mentionLink.dataset.userid ?? ''); | ||
nonLinkMention.classList.add('text-brand', 'font-bold'); | ||
nonLinkMention.textContent = username; | ||
|
||
return [nonLinkMention, mention]; | ||
} | ||
|
||
/** | ||
* Search for mention tags inside an HTML element, and try to match them with a | ||
* provided list of mentions. | ||
* Those that are valid are rendered as links, and those that are not are styled | ||
* in a way that it's possible to visually identify them. | ||
* | ||
* @return - Map of HTML elements that matched a mention tag, with their | ||
* corresponding mention or invalid username | ||
*/ | ||
export function renderMentionTags( | ||
element: HTMLElement, | ||
mentions: Mention[], | ||
): Map<HTMLElement, Mention | InvalidUsername> { | ||
const mentionLinks = element.querySelectorAll('a[data-hyp-mention]'); | ||
const foundMentions = new Map<HTMLElement, Mention | string>(); | ||
|
||
for (const mentionLink of mentionLinks) { | ||
const htmlMentionLink = mentionLink as HTMLElement; | ||
const mentionUserId = htmlMentionLink.dataset.userid; | ||
const mention = mentionUserId | ||
? mentions.find(m => m.userid === mentionUserId) | ||
: undefined; | ||
|
||
foundMentions.set(...elementForMention(htmlMentionLink, mention)); | ||
} | ||
|
||
return foundMentions; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,9 @@ | ||
import { parseAccountID, username, isThirdPartyUser } from '../account-id'; | ||
import { | ||
parseAccountID, | ||
username, | ||
isThirdPartyUser, | ||
buildAccountID, | ||
} from '../account-id'; | ||
|
||
describe('sidebar/helpers/account-id', () => { | ||
const term = 'acct:[email protected]'; | ||
|
@@ -16,6 +21,25 @@ describe('sidebar/helpers/account-id', () => { | |
}); | ||
}); | ||
|
||
describe('buildAccountID', () => { | ||
[ | ||
{ | ||
username: 'john', | ||
provider: 'hypothes.is', | ||
expectedAccountID: 'acct:[email protected]', | ||
}, | ||
{ | ||
username: 'jane', | ||
provider: 'example.com', | ||
expectedAccountID: 'acct:[email protected]', | ||
}, | ||
].forEach(({ username, provider, expectedAccountID }) => { | ||
it('builds userid for username and provider', () => { | ||
assert.equal(buildAccountID(username, provider), expectedAccountID); | ||
}); | ||
}); | ||
}); | ||
|
||
describe('username', () => { | ||
it('should return the username from the account ID', () => { | ||
assert.equal(username(term), 'hacker'); | ||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,121 @@ | ||
import { renderMentionTags, unwrapMentions, wrapMentions } from '../mentions'; | ||
|
||
const mentionTag = (username, authority) => | ||
`<a data-hyp-mention="" data-userid="acct:${username}@${authority}">@${username}</a>`; | ||
|
||
[ | ||
// Mention at the end | ||
{ | ||
text: 'Hello @sean', | ||
authority: 'hypothes.is', | ||
textWithTags: `Hello ${mentionTag('sean', 'hypothes.is')}`, | ||
}, | ||
// Mention at the beginning | ||
{ | ||
text: '@jane look at this', | ||
authority: 'example.com', | ||
textWithTags: `${mentionTag('jane', 'example.com')} look at this`, | ||
}, | ||
// Mention in the middle | ||
{ | ||
text: 'foo @mention bar', | ||
authority: 'example.com', | ||
textWithTags: `foo ${mentionTag('mention', 'example.com')} bar`, | ||
}, | ||
// Multi-line mentions | ||
{ | ||
text: `@username hello | ||
@another how are you | ||
look at @foo comment`, | ||
authority: 'example.com', | ||
textWithTags: `${mentionTag('username', 'example.com')} hello | ||
${mentionTag('another', 'example.com')} how are you | ||
look at ${mentionTag('foo', 'example.com')} comment`, | ||
}, | ||
// No mentions | ||
{ | ||
text: 'Just some text', | ||
authority: 'example.com', | ||
textWithTags: 'Just some text', | ||
}, | ||
// Multiple mentions | ||
{ | ||
text: 'Hey @jane look at this quote from @rob', | ||
authority: 'example.com', | ||
textWithTags: `Hey ${mentionTag('jane', 'example.com')} look at this quote from ${mentionTag('rob', 'example.com')}`, | ||
}, | ||
// Email addresses should be ignored | ||
{ | ||
text: 'Ignore email: [email protected]', | ||
authority: 'example.com', | ||
textWithTags: 'Ignore email: [email protected]', | ||
}, | ||
].forEach(({ text, authority, textWithTags }) => { | ||
describe('wrapMentions', () => { | ||
it('wraps every mention in a mention tag', () => { | ||
assert.equal(wrapMentions(text, authority), textWithTags); | ||
}); | ||
}); | ||
|
||
describe('unwrapMentions', () => { | ||
it('removes wrapping mention tags', () => { | ||
assert.equal(unwrapMentions(textWithTags), text); | ||
}); | ||
}); | ||
}); | ||
|
||
describe('renderMentionTags', () => { | ||
it('processes every mention tag based on provided list of mentions', () => { | ||
const mentions = [ | ||
{ | ||
userid: 'acct:[email protected]', | ||
link: 'http://example.com/janedoe', | ||
}, | ||
{ | ||
userid: 'acct:[email protected]', | ||
link: null, | ||
}, | ||
]; | ||
|
||
const container = document.createElement('div'); | ||
container.innerHTML = ` | ||
<p>Correct mention: ${mentionTag('janedoe', 'hypothes.is')}</p> | ||
<p>Non-link mention: ${mentionTag('johndoe', 'hypothes.is')}</p> | ||
<p>Invalid mention: ${mentionTag('invalid', 'hypothes.is')}</p> | ||
<p>Mention without ID: <a data-hyp-mention="">@user_id_missing</a></p> | ||
`; | ||
|
||
const result = renderMentionTags(container, mentions); | ||
assert.equal(result.size, 4); | ||
|
||
const [ | ||
[firstElement, firstMention], | ||
[secondElement, secondMention], | ||
[thirdElement, thirdMention], | ||
[fourthElement, fourthMention], | ||
] = [...result.entries()]; | ||
|
||
// First element will render as an actual anchor with href | ||
assert.equal(firstElement.tagName, 'A'); | ||
assert.equal( | ||
firstElement.getAttribute('href'), | ||
'http://example.com/janedoe', | ||
); | ||
assert.equal(firstMention, mentions[0]); | ||
|
||
// Second element will render as a highlighted span | ||
assert.equal(secondElement.tagName, 'SPAN'); | ||
assert.equal(secondElement.dataset.userid, 'acct:[email protected]'); | ||
assert.isTrue(secondElement.hasAttribute('data-hyp-mention')); | ||
assert.equal(secondMention, mentions[1]); | ||
|
||
// Third and fourth elements will be invalid mentions wrapping the invalid | ||
// username | ||
assert.equal(thirdElement.tagName, 'SPAN'); | ||
assert.isFalse(thirdElement.hasAttribute('data-hyp-mention')); | ||
assert.equal(thirdMention, '@invalid'); | ||
assert.equal(fourthElement.tagName, 'SPAN'); | ||
assert.isFalse(fourthElement.hasAttribute('data-hyp-mention')); | ||
assert.equal(fourthMention, '@user_id_missing'); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters