diff --git a/commafeed-client/package-lock.json b/commafeed-client/package-lock.json
index dfb77cd3..06f3c7e7 100644
--- a/commafeed-client/package-lock.json
+++ b/commafeed-client/package-lock.json
@@ -23,6 +23,7 @@
"@reduxjs/toolkit": "^1.8.4",
"axios": "^0.27.2",
"dayjs": "^1.11.5",
+ "interweave": "^13.0.0",
"make-plural": "^7.1.0",
"mousetrap": "^1.6.5",
"react": "^18.2.0",
@@ -4583,6 +4584,11 @@
"node": ">=6"
}
},
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
+ },
"node_modules/escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
@@ -6114,6 +6120,21 @@
"node": ">= 0.4"
}
},
+ "node_modules/interweave": {
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/interweave/-/interweave-13.0.0.tgz",
+ "integrity": "sha512-Mckwj+ix/VtrZu1bRBIIohwrsXj12ZTvJCoYUMZlJmgtvIaQCj0i77eSZ63ckbA1TsPrz2VOvLW9/kTgm5d+mw==",
+ "dependencies": {
+ "escape-html": "^1.0.3"
+ },
+ "funding": {
+ "type": "ko-fi",
+ "url": "https://ko-fi.com/milesjohnson"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
"node_modules/invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
@@ -12886,6 +12907,11 @@
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz",
"integrity": "sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw=="
},
+ "escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="
+ },
"escape-string-regexp": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz",
@@ -13992,6 +14018,14 @@
"side-channel": "^1.0.4"
}
},
+ "interweave": {
+ "version": "13.0.0",
+ "resolved": "https://registry.npmjs.org/interweave/-/interweave-13.0.0.tgz",
+ "integrity": "sha512-Mckwj+ix/VtrZu1bRBIIohwrsXj12ZTvJCoYUMZlJmgtvIaQCj0i77eSZ63ckbA1TsPrz2VOvLW9/kTgm5d+mw==",
+ "requires": {
+ "escape-html": "^1.0.3"
+ }
+ },
"invariant": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz",
diff --git a/commafeed-client/package.json b/commafeed-client/package.json
index b851b88e..9558aa79 100644
--- a/commafeed-client/package.json
+++ b/commafeed-client/package.json
@@ -30,6 +30,7 @@
"@reduxjs/toolkit": "^1.8.4",
"axios": "^0.27.2",
"dayjs": "^1.11.5",
+ "interweave": "^13.0.0",
"make-plural": "^7.1.0",
"mousetrap": "^1.6.5",
"react": "^18.2.0",
diff --git a/commafeed-client/src/app/constants.ts b/commafeed-client/src/app/constants.ts
index 0f2f11c2..680c7e80 100644
--- a/commafeed-client/src/app/constants.ts
+++ b/commafeed-client/src/app/constants.ts
@@ -89,6 +89,7 @@ export const Constants = {
mobileBreakpoint: DEFAULT_THEME.breakpoints.md,
headerHeight: 60,
sidebarWidth: 350,
+ entryMaxWidth: 650,
isTopVisible: (div: HTMLDivElement) => div.getBoundingClientRect().top >= Constants.layout.headerHeight,
isBottomVisible: (div: HTMLDivElement) => div.getBoundingClientRect().bottom <= window.innerHeight,
},
diff --git a/commafeed-client/src/app/utils.ts b/commafeed-client/src/app/utils.ts
index 7d9cf323..6a0d32a8 100644
--- a/commafeed-client/src/app/utils.ts
+++ b/commafeed-client/src/app/utils.ts
@@ -19,3 +19,9 @@ export function categoryUnreadCount(category?: Category): number {
.map(f => f.unread)
.reduce((total, current) => total + current, 0)
}
+
+export const calculatePlaceholderSize = ({ width, height, maxWidth }: { width?: number; height?: number; maxWidth: number }) => {
+ const placeholderWidth = width && Math.min(width, maxWidth)
+ const placeholderHeight = height && width && width > maxWidth ? height * (maxWidth / width) : height
+ return { width: placeholderWidth, height: placeholderHeight }
+}
diff --git a/commafeed-client/src/components/ImageWithPlaceholderWhileLoading.tsx b/commafeed-client/src/components/ImageWithPlaceholderWhileLoading.tsx
new file mode 100644
index 00000000..5bde1400
--- /dev/null
+++ b/commafeed-client/src/components/ImageWithPlaceholderWhileLoading.tsx
@@ -0,0 +1,51 @@
+import { Box, Center, createStyles } from "@mantine/core"
+import { useState } from "react"
+import { TbPhoto } from "react-icons/tb"
+
+interface ImageWithPlaceholderWhileLoadingProps {
+ src?: string
+ alt?: string
+ title?: string
+ width?: number
+ height?: number | "auto"
+ placeholderWidth?: number
+ placeholderHeight?: number
+}
+
+const useStyles = createStyles((theme, props: ImageWithPlaceholderWhileLoadingProps) => ({
+ placeholder: {
+ width: props.placeholderWidth ?? 400,
+ height: props.placeholderHeight ?? 600,
+ maxWidth: "100%",
+ color: theme.fn.variant({ color: theme.primaryColor, variant: "subtle" }).color,
+ backgroundColor: theme.colorScheme === "dark" ? theme.colors.dark[5] : theme.colors.gray[1],
+ },
+}))
+
+export function ImageWithPlaceholderWhileLoading(props: ImageWithPlaceholderWhileLoadingProps) {
+ const { classes } = useStyles(props)
+ const [loading, setLoading] = useState(true)
+
+ return (
+ <>
+ {loading && (
+
+
+
+
+
+
+
+ )}
+
setLoading(false)}
+ style={{ display: loading ? "none" : "block" }}
+ />
+ >
+ )
+}
diff --git a/commafeed-client/src/components/content/Content.tsx b/commafeed-client/src/components/content/Content.tsx
index 82aff853..c89e61aa 100644
--- a/commafeed-client/src/components/content/Content.tsx
+++ b/commafeed-client/src/components/content/Content.tsx
@@ -1,4 +1,8 @@
-import { createStyles, Text, TypographyStylesProvider } from "@mantine/core"
+import { Box, createStyles, TypographyStylesProvider } from "@mantine/core"
+import { Constants } from "app/constants"
+import { calculatePlaceholderSize } from "app/utils"
+import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
+import { Interweave, TransformCallback } from "interweave"
export interface ContentProps {
content: string
@@ -11,10 +15,6 @@ const useStyles = createStyles(theme => ({
"& a": {
color: theme.fn.variant({ color: theme.primaryColor, variant: "subtle" }).color,
},
- "& img": {
- maxWidth: "100%",
- height: "auto",
- },
"& iframe": {
maxWidth: "100%",
},
@@ -24,11 +24,42 @@ const useStyles = createStyles(theme => ({
},
}))
+const transform: TransformCallback = node => {
+ if (node.tagName === "IMG") {
+ // show placeholders for loading img tags, this allows the entry to have its final height immediately
+ const src = node.getAttribute("src") ?? undefined
+ const alt = node.getAttribute("alt") ?? undefined
+ const title = node.getAttribute("title") ?? undefined
+ const width = node.getAttribute("width") ? parseInt(node.getAttribute("width")!, 10) : undefined
+ const height = node.getAttribute("height") ? parseInt(node.getAttribute("height")!, 10) : undefined
+ const placeholderSize = calculatePlaceholderSize({
+ width,
+ height,
+ maxWidth: Constants.layout.entryMaxWidth,
+ })
+ return (
+
+ )
+ }
+ return undefined
+}
+
export function Content(props: ContentProps) {
const { classes } = useStyles()
+
return (
-
+
+
+
)
}
diff --git a/commafeed-client/src/components/content/Enclosure.tsx b/commafeed-client/src/components/content/Enclosure.tsx
index 5b6641b2..46bb9c18 100644
--- a/commafeed-client/src/components/content/Enclosure.tsx
+++ b/commafeed-client/src/components/content/Enclosure.tsx
@@ -1,19 +1,13 @@
-import { createStyles } from "@mantine/core"
-
-const useStyles = createStyles(() => ({
- enclosureImage: {
- maxWidth: "100%",
- height: "auto",
- },
-}))
+import { TypographyStylesProvider } from "@mantine/core"
+import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
export function Enclosure(props: { enclosureType?: string; enclosureUrl?: string }) {
- const { classes } = useStyles()
const hasVideo = props.enclosureType && props.enclosureType.indexOf("video") === 0
const hasAudio = props.enclosureType && props.enclosureType.indexOf("audio") === 0
const hasImage = props.enclosureType && props.enclosureType.indexOf("image") === 0
+
return (
- <>
+
{hasVideo && (
// eslint-disable-next-line jsx-a11y/media-has-caption
)
}
diff --git a/commafeed-client/src/components/content/FeedEntry.tsx b/commafeed-client/src/components/content/FeedEntry.tsx
index ddb8e9a6..793d09dd 100644
--- a/commafeed-client/src/components/content/FeedEntry.tsx
+++ b/commafeed-client/src/components/content/FeedEntry.tsx
@@ -29,7 +29,7 @@ const useStyles = createStyles((theme, props: FeedEntryProps) => {
},
},
body: {
- maxWidth: "650px",
+ maxWidth: Constants.layout.entryMaxWidth,
},
}
})
diff --git a/commafeed-client/src/components/content/Media.tsx b/commafeed-client/src/components/content/Media.tsx
index 010ab622..231136e1 100644
--- a/commafeed-client/src/components/content/Media.tsx
+++ b/commafeed-client/src/components/content/Media.tsx
@@ -1,4 +1,7 @@
-import { Box, createStyles } from "@mantine/core"
+import { Box, TypographyStylesProvider } from "@mantine/core"
+import { Constants } from "app/constants"
+import { calculatePlaceholderSize } from "app/utils"
+import { ImageWithPlaceholderWhileLoading } from "components/ImageWithPlaceholderWhileLoading"
import { Content } from "./Content"
export interface MediaProps {
@@ -8,29 +11,29 @@ export interface MediaProps {
description?: string
}
-const useStyles = createStyles(() => ({
- image: {
- maxWidth: "100%",
- height: "auto",
- },
-}))
-
export function Media(props: MediaProps) {
- const { classes } = useStyles()
+ const width = props.thumbnailWidth
+ const height = props.thumbnailHeight
+ const placeholderSize = calculatePlaceholderSize({
+ width,
+ height,
+ maxWidth: Constants.layout.entryMaxWidth,
+ })
return (
- <>
-
+
{props.description && (
)}
- >
+
)
}
diff --git a/commafeed-server/config.dev.yml b/commafeed-server/config.dev.yml
index e98bdb7a..ecbfc2cb 100644
--- a/commafeed-server/config.dev.yml
+++ b/commafeed-server/config.dev.yml
@@ -50,7 +50,7 @@ app:
# if enabled, images in feed entries will be proxied through the server instead of accessed directly by the browser
# useful if commafeed is usually accessed through a restricting proxy
- imageProxyEnabled: false
+ imageProxyEnabled: true
# database query timeout (in milliseconds), 0 to disable
queryTimeout: 0