Skip to content
Open
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
62 changes: 60 additions & 2 deletions src/components/email-viewer/email-viewer.scss
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
@use '../../style/mixins';

:host(limel-email-viewer) {
isolation: isolate;

display: block;
width: 100%;
height: 100%;
Expand Down Expand Up @@ -72,6 +74,7 @@

&.date {
position: absolute;
z-index: 1;
right: 0.25rem;
bottom: 0;
transform: translateY(50%);
Expand Down Expand Up @@ -156,7 +159,6 @@ section {
flex-direction: column;
border-top: 1px dashed rgba(var(--contrast-700));
min-height: 2rem;
overflow-y: auto;
}

limel-collapsible-section {
Expand Down Expand Up @@ -198,7 +200,16 @@ limel-collapsible-section {
.body {
flex-grow: 1;
max-width: 100%;
padding: 0.75rem;
padding: 1rem;
padding-bottom: 3rem;
overflow-y: auto;

background-color: rgb(var(--contrast-300));
color-scheme: light;
&.toggled-surface-background {
background-color: rgb(var(--contrast-1500));
color-scheme: dark;
}
Comment thread
Kiarokh marked this conversation as resolved.

&.plain-text {
white-space: pre-wrap;
Expand All @@ -211,3 +222,50 @@ limel-collapsible-section {
max-width: 100% !important;
}
}

.toggle-dark-light {
display: flex;
align-items: center;
justify-content: flex-end;

position: sticky;
top: 0;
z-index: 1;

padding: 0.25rem 0.5rem;
$height: 2.5rem;
height: $height;
margin-bottom: -$height;

pointer-events: none;

button {
pointer-events: all;
@include mixins.reset-button-user-agent-styles;
@include mixins.is-elevated-clickable();
@include mixins.visualize-keyboard-focus();

display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;

padding: 0.125rem 0.25rem;
height: fit-content;

font-size: 0.75rem;
border-radius: 0.375rem;
border: 1px solid rgba(var(--contrast-700));

opacity: 0.6;

&:hover,
&:focus-visible {
opacity: 1;
}

limel-icon {
width: 1.125rem;
}
}
}
70 changes: 67 additions & 3 deletions src/components/email-viewer/email-viewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import { applyRemoteImagesPolicy, containsRemoteImages } from './remote-images';
import { splitEmailAddressList } from './split-email-address-list';
import { formatBytes } from '../../util/format-bytes';
import { createRandomString } from '../../util/random-string';

/**
* This is a private component, used to render `.eml` files inside
Expand Down Expand Up @@ -70,6 +71,11 @@
@State()
private allowRemoteImagesState = false;

@State()
private surfaceBackgroundState = false;

private toggleButtonId = createRandomString();

Check warning on line 77 in src/components/email-viewer/email-viewer.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member 'toggleButtonId' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=Lundalogik_lime-elements&issues=AZ4DR5q2jG2S8acqVQiz&open=AZ4DR5q2jG2S8acqVQiz&pullRequest=4066

/**
* Emitted when the user requests remote images to be loaded.
*
Expand All @@ -79,7 +85,11 @@
public allowRemoteImagesChange: EventEmitter<boolean>;

@Watch('email')
protected resetAllowRemoteImages(newEmail?: Email, oldEmail?: Email) {
protected handleEmailChange(newEmail?: Email, oldEmail?: Email) {
// Surface preference is per-email-view: a new message always opens
// with the default light background.
this.surfaceBackgroundState = false;

if (!newEmail) {
this.allowRemoteImagesState = false;
return;
Expand All @@ -98,6 +108,7 @@
{this.renderRemoteImageBanner()}
<section>
{this.renderAttachments()}
{this.renderSurfaceToggle()}
{this.renderBody()}
</section>
</div>
Expand Down Expand Up @@ -146,7 +157,13 @@
this.getAllowRemoteImages()
);

return <div class="body" innerHTML={innerHtml} part="email-body" />;
return (
<div
class={this.getBodyClassNames()}
innerHTML={innerHtml}
part="email-body"
/>
);
}

private renderBodyText() {
Expand All @@ -156,12 +173,59 @@
}

return (
<pre class="body plain-text" part="email-body">
<pre
class={`${this.getBodyClassNames()} plain-text`}
part="email-body"
>
{bodyText}
</pre>
);
}

private getBodyClassNames(): string {
return this.surfaceBackgroundState
? 'body toggled-surface-background'
: 'body';
}

private renderSurfaceToggle() {
const hasBody = Boolean(this.email?.bodyHtml || this.email?.bodyText);
if (!hasBody) {
return;
}

const label = this.getTranslation('file-viewer.email.surface.toggle');
const tooltipLabel = this.getTranslation(
'file-viewer.email.surface.toggle.tooltip.label'
);
const tooltipHelper = this.getTranslation(
'file-viewer.email.surface.toggle.tooltip.helper'
);

return (
<div class="toggle-dark-light">
<button
id={this.toggleButtonId}
type="button"
aria-pressed={String(this.surfaceBackgroundState)}
onClick={this.toggleSurfaceBackground}
>
<limel-icon name="-lime-dark-light-mode" />
{label}
</button>
<limel-tooltip
elementId={this.toggleButtonId}
label={tooltipLabel}
helperLabel={tooltipHelper}
/>
</div>
);
}

private toggleSurfaceBackground = () => {

Check warning on line 225 in src/components/email-viewer/email-viewer.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Member 'toggleSurfaceBackground' is never reassigned; mark it as `readonly`.

See more on https://sonarcloud.io/project/issues?id=Lundalogik_lime-elements&issues=AZ4DR5q2jG2S8acqVQi0&open=AZ4DR5q2jG2S8acqVQi0&pullRequest=4066
this.surfaceBackgroundState = !this.surfaceBackgroundState;
};

private renderFallbackUrl() {
if (!this.fallbackUrl) {
return;
Expand Down
28 changes: 23 additions & 5 deletions src/components/file-viewer/file-viewer.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,29 @@ describe('limel-file-viewer', () => {
'\r\n' +
'Hello from EML!\r\n';

globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
arrayBuffer: async () =>
new TextEncoder().encode(eml).buffer,
} as any);
// Route the email URL to its EML payload, and return a
// benign empty response for any other fetches the email
// viewer's render tree may trigger (e.g. icon SVGs from
// <limel-icon>, which calls `response.text()`).
globalThis.fetch = vi
.fn()
.mockImplementation(async (input: any) => {
const url =
typeof input === 'string' ? input : input.url;
if (url === testCase.url) {
return {
ok: true,
arrayBuffer: async () =>
new TextEncoder().encode(eml).buffer,
} as any;
}

return {
ok: true,
text: async () => '',
arrayBuffer: async () => new ArrayBuffer(0),
} as any;
});
}

const { root, waitForChanges } = await render(
Expand Down
2 changes: 0 additions & 2 deletions src/components/markdown/allowed-css-properties.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
export const allowedCssProperties = [
'background-color',
'color',
'font-style',
'font-weight',
'text-decoration-color',
Expand Down
13 changes: 5 additions & 8 deletions src/components/markdown/examples/markdown-html.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,19 @@ const markdown = `
<dd>It's better to use HTML <em>tags</em>.</dd>

<dt>Can you use text colors?</dt>
<dd style="color: red">Yes, since I'm red!</dd>
<dd style="color: red">No. Inline <code>color</code> styles are stripped so the text always inherits the surrounding theme — this stays readable in both light and dark mode.</dd>

<dt>Can you use background colors?</dt>
<dd style="background-color: rgb(var(--color-green-default))">Yes, since I'm on a green background!</dd>
<dd style="background-color: rgb(var(--color-green-default))">No. Inline <code>background-color</code> styles are stripped for the same reason.</dd>

<dt>Can you use more than one style at the same time?</dt>
<dd style="color: rgb(var(--color-sky-lighter)); background-color: rgb(var(--color-coral-dark)); font-weight: bold;">Yes, since I'm a light sky blue on a dark coral background!</dd>
<dt>Can you make text bold or italic with inline styles?</dt>
<dd style="font-weight: bold; font-style: italic;">Yes, since <code>font-weight</code> and <code>font-style</code> are allowed.</dd>

<dt>Can you use background images?</dt>
<dd style="background-image: url(https://lundalogik.github.io/lime-icons8/assets/icons/poison.svg)">No, you should not be able to, so if there's a skull and crossbones background here, something is wrong.</dd>

<dt>Can you use <code>background</code> with a color value?</dt>
<dd style="background: #4ca250">Yes. If the value is recognized as a color value, the value will be moved to <code>background-color</code></dd>

<dt>Can you sneakily use <code>background</code> to insert an image?</dt>
<dd style="background: #4ca250 url(https://lundalogik.github.io/lime-icons8/assets/icons/poison.svg)">No. If the value is not recognized as a color value, the background property will be stripped.</dd>
<dd style="background: #4ca250 url(https://lundalogik.github.io/lime-icons8/assets/icons/poison.svg)">No, the <code>background</code> shorthand is stripped entirely.</dd>
</dl>
`;

Expand Down
16 changes: 7 additions & 9 deletions src/components/markdown/markdown-parser.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -629,27 +629,25 @@ describe('sanitizeHTML', () => {
describe('style attribute sanitization', () => {
it('should allow safe CSS properties', async () => {
const result = await sanitizeHTML(
'<p style="color: red; font-weight: bold;">Text</p>'
'<p style="font-style: italic; font-weight: bold;">Text</p>'
);
expect(result).toEqualHtml(
'<p style="color: red; font-weight: bold">Text</p>'
'<p style="font-style: italic; font-weight: bold">Text</p>'
);
});

it('should strip dangerous CSS properties', async () => {
const result = await sanitizeHTML(
'<p style="color: red; position: absolute; z-index: 9999;">Text</p>'
'<p style="font-weight: bold; position: absolute; z-index: 9999;">Text</p>'
);
expect(result).toEqualHtml('<p style="color: red">Text</p>');
expect(result).toEqualHtml('<p style="font-weight: bold">Text</p>');
});

it('should normalize background to background-color', async () => {
it('should strip color and background-color', async () => {
const result = await sanitizeHTML(
'<p style="background: blue;">Text</p>'
);
expect(result).toEqualHtml(
'<p style="background-color: blue">Text</p>'
'<p style="color: red; background-color: blue; font-weight: bold;">Text</p>'
);
expect(result).toEqualHtml('<p style="font-weight: bold">Text</p>');
});
});

Expand Down
Loading
Loading