Skip to content

Commit

Permalink
Create helper functions to manipulate mentions
Browse files Browse the repository at this point in the history
  • Loading branch information
acelaya committed Feb 6, 2025
1 parent 6a8e7ae commit e9d9845
Show file tree
Hide file tree
Showing 5 changed files with 289 additions and 1 deletion.
8 changes: 8 additions & 0 deletions src/sidebar/helpers/account-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ export function parseAccountID(user: string | null) {
};
}

/**
* Build H account names of the form 'acct:<username>@<provider>'
* from a username and provider.
*/
export function buildAccountID(username: string, provider: string): string {
return `acct:${username}@${provider}`;
}

/**
* Returns the username part of an account ID or an empty string.
*/
Expand Down
117 changes: 117 additions & 0 deletions src/sidebar/helpers/mentions.ts
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;
}
26 changes: 25 additions & 1 deletion src/sidebar/helpers/test/account-id-test.js
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]';
Expand All @@ -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');
Expand Down
121 changes: 121 additions & 0 deletions src/sidebar/helpers/test/mentions-test.js
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');
});
});
18 changes: 18 additions & 0 deletions src/types/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,24 @@ export type UserInfo = {
display_name: string | null;
};

export type Mention = {
/** Current userid for the user that was mentioned */
userid: string;
/** Current username for the user that was mentioned */
username: string;
/** Current display name for the user that was mentioned */
display_name: string | null;
/** Link to the user profile, if applicable */
link: string | null;

/**
* The userid at the moment the mention was created.
* If the user changes their username later, this can be used to match the
* right mention tag in the annotation text.
*/
original_userid: string;
};

/**
* Represents an annotation as returned by the h API.
* API docs: https://h.readthedocs.io/en/latest/api-reference/#tag/annotations
Expand Down

0 comments on commit e9d9845

Please sign in to comment.