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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"@angular/platform-browser-dynamic": "~9.0.1",
"@angular/router": "~9.0.1",
"@angular/service-worker": "~9.0.1",
"dompurify": "3.4.13",

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 dompurify pin differs from description

package.json pins dompurify to 3.4.13 while the PR description says 3.2.6. Both are exact pins so builds stay deterministic; the mismatch is only between code and description. Confirm the intended version.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3.4.13 is the intended version — the description was written against the original 3.2.6 pin and has since been updated. 3.2.6 carries 20 open Snyk findings (16 medium, 4 low: XSS, prototype pollution), which is a poor place to be for a sanitizer that ships in the browser bundle; 3.4.13 clears all of them within the same major and is confirmed gone from a rescan.

"node-fetch": "^2.6.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
Expand Down
2 changes: 2 additions & 0 deletions src-react/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useSettings } from './shared/settings/SettingsContext';
import Footer from './core/footer/Footer';
import Header from './core/header/Header';
import Feed from './feeds/feed/Feed';
import ItemDetails from './item-details/ItemDetails';
import type { FeedName } from './shared/models';
import './App.scss';

Expand All @@ -26,6 +27,7 @@ function App() {
element={<Feed key={feedName} feedType={feedName} />}
/>
))}
<Route path="item/:id" element={<ItemDetails />} />
</Routes>
<Footer />
</div>
Expand Down
151 changes: 151 additions & 0 deletions src-react/item-details/ItemDetails.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
@import "../shared/scss/media";
@import "../shared/scss/theme_variables";

.main-content {
position: relative;
width: 100%;
min-height: 100vh;
-webkit-transition: opacity .2s ease;
transition: opacity .2s ease;
box-sizing: border-box;
padding: 8px 0;
z-index: 0;
}

.item {
box-sizing: border-box;
padding: 10px 40px 0 40px;
z-index: 0;
}

@media #{$tablet-only} {
.item {
padding: 10px 20px 0 40px;
}
}

@media #{$mobile-only} {
.item {
box-sizing: border-box;
padding: 110px 15px 0 15px;
}
}

.head-margin {
margin-bottom: 15px;
}

p {
margin: 2px 0;
}

.subject {
word-wrap: break-word;
margin-top: 20px;
}

a {
cursor: pointer;
text-decoration: none;
}

@media #{$mobile-only} {
.laptop {
display: none;
}
}

@media #{$laptop-only} {
.mobile {
display: none;
}
}

.title {
font-size: 16px;
font-family: Verdana, Geneva, sans-serif;
}

.title-block {
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
margin: 0 75px;
}

@media #{$mobile-only} {
.title {
font-size: 15px;
}
.back-button {
position: absolute;
top: 52%;
width: 0.6rem;
height: 0.6rem;
background: transparent;
box-shadow: 0 0 0 lightgray;
transition: all 200ms ease;
left: 4%;
transform: translate3d(0, -50%, 0) rotate(-135deg);
}
}

.subtext {
font-size: 12px;
font-weight: bold;
letter-spacing: 0.5px;
}

.domain {
letter-spacing: 0.5px;
}

.subtext a {
&:hover {
text-decoration: underline;
}
}

.item-details {
padding: 10px;
}

.item-header {
padding-bottom: 10px;
}

@media #{$mobile-only} {
.item-header {
padding: 10px 0 10px 0;
position: fixed;
width: 100%;
left: 0;
top: 62px;
}
}

.pollResults {
margin-bottom: 1em;
}

.pollContent {
* {
padding-bottom: 0;
margin-bottom: -1em;
margin-top: 1em;
}
.pollBar {
height: 10px;
margin-bottom: 1em;
}
}

ul {
list-style-type: none;
padding: 10px 0;
}

li {
display: list-item;
}
144 changes: 144 additions & 0 deletions src-react/item-details/ItemDetails.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { useEffect, useState } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';

import { fetchItemContent } from '../shared/api/hackernewsApi';
import ErrorMessage from '../shared/components/error-message/ErrorMessage';
import Loader from '../shared/components/loader/Loader';
import type { Story } from '../shared/models';
import { useSettings } from '../shared/settings/SettingsContext';
import { formatComments } from '../shared/utils/formatComments';
import { sanitizeHtml } from '../shared/utils/sanitizeHtml';
import Comment from './comment/Comment';
import './ItemDetails.scss';

function ItemDetails() {
const params = useParams<{ id?: string }>();
const itemID = Number(params.id);
const navigate = useNavigate();
const { settings } = useSettings();
const [item, setItem] = useState<Story>();
const [errorMessage, setErrorMessage] = useState('');

useEffect(() => {
let ignore = false;
window.scrollTo(0, 0);
setErrorMessage('');

fetchItemContent(itemID)
.then((nextItem) => {
if (!ignore) {
setItem(nextItem);
}
})
.catch(() => {
if (!ignore) {
setErrorMessage('Could not load item comments.');
}
});

return () => {
ignore = true;
};
}, [itemID]);
Comment on lines +22 to +42

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Stale item shown during item-to-item navigation

The effect resets errorMessage but not item before fetching, so navigating between items keeps the previous item's content and comments visible with no loader until the new fetch resolves. This matches the original Angular behavior, so it is not a regression.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


const goBack = () => {
navigate(-1);
};

const hasUrl = item?.url?.indexOf('http') === 0;

return (
<div className="main-content">
{!item && !errorMessage && <Loader />}
{!item && errorMessage !== '' && <ErrorMessage message={errorMessage} />}
{item && (
<div className="item">
<div className="mobile item-header">
<p className="title-block">
<span className="back-button" onClick={goBack} />
{hasUrl ? (
<a
className="title"
href={item.url}
target={settings.openLinkInNewTab ? '_blank' : undefined}
rel={settings.openLinkInNewTab ? 'noopener' : undefined}
>
{item.title}
</a>
) : (
<Link className="title" to={`/item/${item.id}`}>
{item.title}
</Link>
)}
</p>
</div>
<div
className={`laptop${item.comments_count > 0 || item.type === 'job' ? ' item-header' : ''}${
item.text ? ' head-margin' : ''
}`}
>
{hasUrl ? (
<p>
<a
className="title"
href={item.url}
target={settings.openLinkInNewTab ? '_blank' : undefined}
rel={settings.openLinkInNewTab ? 'noopener' : undefined}
>
{item.title}
</a>
{item.domain && <span className="domain">({item.domain})</span>}
</p>
) : (
<p>
<Link className="title" to={`/item/${item.id}`}>
{item.title}
</Link>
</p>
)}
<div className="subtext">
{item.type !== 'job' && (
<span>
{item.points} points by <Link to={`/user/${item.user}`}>{item.user}</Link>
</span>
)}
<span className={item.type !== 'job' ? 'item-details' : undefined}>
{item.time_ago}
{item.type !== 'job' && (
<span>
{' | '}
<Link to={`/item/${item.id}`}>{formatComments(item.comments_count)}</Link>
</span>
)}
</span>
</div>
</div>
{item.type === 'poll' && (
<div className="pollResults">
{item.poll.map((pollResult, index) => (
<div className="pollContent" key={index}>
<div dangerouslySetInnerHTML={{ __html: sanitizeHtml(pollResult.content) }} />
<div className="subtext">{pollResult.points} points</div>
<div
className="pollBar"
style={{ width: `${(pollResult.points / item.poll_votes_count) * 100}%` }}
/>
</div>
))}
</div>
)}
<p className="subject" dangerouslySetInnerHTML={{ __html: sanitizeHtml(item.content ?? '') }} />
<ul className="comment-list">
{(item.comments ?? []).map((comment) => (
<li key={comment.id}>
<Comment comment={comment} />
</li>
))}
</ul>
</div>
)}
</div>
);
}

export default ItemDetails;
Loading