feat: pictures masonry (#212)

* feat: masonry

Signed-off-by: Innei <i@innei.in>

* fix: layout

Signed-off-by: Innei <i@innei.in>

* refactor

Signed-off-by: Innei <i@innei.in>

* update

Signed-off-by: Innei <i@innei.in>

* proxy

Signed-off-by: Innei <i@innei.in>

* update

Signed-off-by: Innei <i@innei.in>

* update

Signed-off-by: Innei <i@innei.in>

* update

Signed-off-by: Innei <i@innei.in>

* fix: stable

Signed-off-by: Innei <i@innei.in>

* move

Signed-off-by: Innei <i@innei.in>

* fix: endpoint

Signed-off-by: Innei <i@innei.in>

* chore: add tracker

Signed-off-by: Innei <i@innei.in>

* fix: remove fetch meta info

Signed-off-by: Innei <i@innei.in>

* fix: seo

Signed-off-by: Innei <i@innei.in>

* fix: align

Signed-off-by: Innei <i@innei.in>

* feat: add close button

Signed-off-by: Innei <i@innei.in>

* feat: set pictureViewMasonry default to true

---------

Signed-off-by: Innei <i@innei.in>
Co-authored-by: DIYgod <i@diygod.me>
This commit is contained in:
Innei 2024-08-09 15:07:59 +08:00 committed by GitHub
parent 7678bd4860
commit 3f9533aa3a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
43 changed files with 1076 additions and 328 deletions

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#fff" fill-opacity=".01" d="M24 0v24H0V0z"/><path stroke="#10161F" stroke-linejoin="round" stroke-width="2" d="M20.5 5.75c0-.232 0-.348-.01-.446a2 2 0 0 0-1.794-1.794c-.098-.01-.214-.01-.446-.01h-2c-.232 0-.348 0-.446.01a2 2 0 0 0-1.794 1.794c-.01.098-.01.214-.01.446s0 .348.01.446a2 2 0 0 0 1.794 1.794c.098.01.214.01.446.01h2c.232 0 .348 0 .446-.01a2 2 0 0 0 1.794-1.794c.01-.098.01-.214.01-.446zM10 6.75c0-1.167 0-1.751-.236-2.193a2 2 0 0 0-.821-.82C8.5 3.5 7.917 3.5 6.75 3.5s-1.751 0-2.193.236a2 2 0 0 0-.82.821C3.5 5 3.5 5.583 3.5 6.75v2c0 1.167 0 1.751.236 2.193a2 2 0 0 0 .821.82C5 12 5.583 12 6.75 12s1.751 0 2.193-.236a2 2 0 0 0 .82-.821C10 10.5 10 9.917 10 8.75zM20.5 15.25c0-1.167 0-1.751-.236-2.193a2 2 0 0 0-.821-.82C19 12 18.417 12 17.25 12s-1.751 0-2.193.236a2 2 0 0 0-.82.821C14 13.5 14 14.083 14 15.25v2c0 1.167 0 1.751.236 2.193a2 2 0 0 0 .821.82c.442.237 1.026.237 2.193.237s1.751 0 2.193-.236a2 2 0 0 0 .82-.821c.237-.442.237-1.026.237-2.193zM10 18.25c0-.232 0-.348-.01-.446a2 2 0 0 0-1.794-1.794C8.098 16 7.982 16 7.75 16h-2c-.232 0-.348 0-.446.01a2 2 0 0 0-1.794 1.794c-.01.098-.01.214-.01.446s0 .348.01.446a2 2 0 0 0 1.794 1.794c.098.01.214.01.446.01h2c.232 0 .348 0 .446-.01a2 2 0 0 0 1.794-1.794c.01-.098.01-.214.01-.446z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none"><path fill="#fff" fill-opacity=".01" d="M24 0v24H0V0z"/><path stroke="#10161F" stroke-linejoin="round" stroke-width="2" d="M20.5 6.75c0-.697 0-1.046-.07-1.334a2.5 2.5 0 0 0-1.846-1.847c-.288-.069-.637-.069-1.334-.069-.697 0-1.046 0-1.334.07a2.5 2.5 0 0 0-1.847 1.846C14 5.704 14 6.053 14 6.75c0 .697 0 1.046.07 1.334a2.5 2.5 0 0 0 1.846 1.847c.288.069.637.069 1.334.069.697 0 1.046 0 1.334-.07a2.5 2.5 0 0 0 1.847-1.846c.069-.288.069-.637.069-1.334zM10 6.75c0-.697 0-1.046-.07-1.334A2.5 2.5 0 0 0 8.085 3.57C7.796 3.5 7.447 3.5 6.75 3.5c-.697 0-1.046 0-1.334.07A2.5 2.5 0 0 0 3.57 5.415C3.5 5.704 3.5 6.053 3.5 6.75c0 .697 0 1.046.07 1.334A2.5 2.5 0 0 0 5.415 9.93C5.704 10 6.053 10 6.75 10c.697 0 1.046 0 1.334-.07A2.5 2.5 0 0 0 9.93 8.085C10 7.796 10 7.447 10 6.75zM20.5 17.25c0-.697 0-1.046-.07-1.334a2.5 2.5 0 0 0-1.846-1.847C18.296 14 17.947 14 17.25 14c-.697 0-1.046 0-1.334.07a2.5 2.5 0 0 0-1.847 1.846C14 16.204 14 16.553 14 17.25c0 .697 0 1.046.07 1.334a2.5 2.5 0 0 0 1.846 1.847c.288.069.637.069 1.334.069.697 0 1.046 0 1.334-.07a2.5 2.5 0 0 0 1.847-1.846c.069-.288.069-.637.069-1.334zM10 17.25c0-.697 0-1.046-.07-1.334a2.5 2.5 0 0 0-1.846-1.847C7.796 14 7.447 14 6.75 14c-.697 0-1.046 0-1.334.07a2.5 2.5 0 0 0-1.847 1.846c-.069.288-.069.637-.069 1.334 0 .697 0 1.046.07 1.334a2.5 2.5 0 0 0 1.846 1.847c.288.069.637.069 1.334.069.697 0 1.046 0 1.334-.07a2.5 2.5 0 0 0 1.847-1.846c.069-.288.069-.637.069-1.334z"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -31,6 +31,7 @@
"typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false"
},
"dependencies": {
"@egjs/react-infinitegrid": "4.12.0",
"@egoist/tipc": "0.3.2",
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^3.0.0",
@ -124,6 +125,7 @@
"tailwind-merge": "2.4.0",
"tldts": "6.1.35",
"unified": "11.0.5",
"use-context-selector": "2.0.0",
"usehooks-ts": "3.1.0",
"vfile": "6.0.2",
"zod": "3.23.8",

View File

@ -22,6 +22,9 @@ importers:
.:
dependencies:
'@egjs/react-infinitegrid':
specifier: 4.12.0
version: 4.12.0
'@egoist/tipc':
specifier: 0.3.2
version: 0.3.2(electron@30.1.2)(react@18.3.1)
@ -301,6 +304,9 @@ importers:
unified:
specifier: 11.0.5
version: 11.0.5
use-context-selector:
specifier: 2.0.0
version: 2.0.0(react@18.3.1)(scheduler@0.23.2)
usehooks-ts:
specifier: 3.1.0
version: 3.1.0(react@18.3.1)
@ -625,10 +631,37 @@ packages:
resolution: {integrity: sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==}
engines: {node: '>=6.9.0'}
'@cfcs/core@0.0.24':
resolution: {integrity: sha512-feB38qu+eDk0Pggh/yR7gjaNmvUYA2uCxHP3Pz2MLE4LZ/9jPdtu8bzCSI47yTEhWyZCF5Pk698hdz8IN2mTjA==}
'@cfcs/core@0.0.5':
resolution: {integrity: sha512-TZ/RXKV7MUrqFpiX+16uaaptv5jgChWNsuE6w8Rn3eJbO1PFd4Wpn25zeJbii7ch46ck8/ZIIYCcW3pHiYtm4Q==}
'@develar/schema-utils@2.6.5':
resolution: {integrity: sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig==}
engines: {node: '>= 8.9.0'}
'@egjs/children-differ@1.0.1':
resolution: {integrity: sha512-DRvyqMf+CPCOzAopQKHtW+X8iN6Hy6SFol+/7zCUiE5y4P/OB8JP8FtU4NxtZwtafvSL4faD5KoQYPj3JHzPFQ==}
'@egjs/component@3.0.5':
resolution: {integrity: sha512-cLcGizTrrUNA2EYE3MBmEDt2tQv1joVP1Q3oDisZ5nw0MZDx2kcgEXM+/kZpfa/PAkFvYVhRUZwytIQWoN3V/w==}
'@egjs/grid@1.16.0':
resolution: {integrity: sha512-w344hL2HNwmOHu379EG9I8ULkWF4A0cVf1XDn5nGlLXc+8c/rMvQadMBkGPOcdWMEKvFZpEiOPI7tWlXLOCloQ==}
'@egjs/imready@1.4.1':
resolution: {integrity: sha512-JIOBs4lB7FYdsKi5uvz2j3SObX8eShtZjtqlOH41tm185aJOQZwiKBK8+V4MxzG4X6DqVhpdN8UcuVwBbElfsg==}
'@egjs/infinitegrid@4.12.0':
resolution: {integrity: sha512-zoDz+mag7DAropcgmxW0Bx3JQ5ID19lk+AUN9rf9U7diwmO8IBfddX44jOVc9+ya5yZ+Goj95Fkm5S3GqCOrZQ==}
'@egjs/list-differ@1.0.1':
resolution: {integrity: sha512-OTFTDQcWS+1ZREOdCWuk5hCBgYO4OsD30lXcOCyVOAjXMhgL5rBRDnt/otb6Nz8CzU0L/igdcaQBDLWc4t9gvg==}
'@egjs/react-infinitegrid@4.12.0':
resolution: {integrity: sha512-V661KH1nka13wgy73N0IGBazZGe7LiECml1JDqB5DP6axTERUVCs8KRHadHu6oQihDeXwJIHe1YRhhMN9eAnUw==}
'@egoist/tailwindcss-icons@1.8.1':
resolution: {integrity: sha512-hqZeASrhT6BOeaBHYDPB0yBH/zgMKqmm7y2Rsq0c4iRnNVv1RWEiXMBMJB38JsDMTHME450FKa/wvdaIhED+Iw==}
peerDependencies:
@ -6621,6 +6654,12 @@ packages:
'@types/react':
optional: true
use-context-selector@2.0.0:
resolution: {integrity: sha512-owfuSmUNd3eNp3J9CdDl0kMgfidV+MkDvHPpvthN5ThqM+ibMccNE0k+Iq7TWC6JPFvGZqanqiGCuQx6DyV24g==}
peerDependencies:
react: '>=18.0.0'
scheduler: '>=0.19.0'
use-isomorphic-layout-effect@1.1.2:
resolution: {integrity: sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==}
peerDependencies:
@ -7155,11 +7194,50 @@ snapshots:
'@babel/helper-validator-identifier': 7.24.7
to-fast-properties: 2.0.0
'@cfcs/core@0.0.24':
dependencies:
'@egjs/component': 3.0.5
'@cfcs/core@0.0.5':
dependencies:
'@egjs/component': 3.0.5
'@develar/schema-utils@2.6.5':
dependencies:
ajv: 6.12.6
ajv-keywords: 3.5.2(ajv@6.12.6)
'@egjs/children-differ@1.0.1':
dependencies:
'@egjs/list-differ': 1.0.1
'@egjs/component@3.0.5': {}
'@egjs/grid@1.16.0':
dependencies:
'@egjs/children-differ': 1.0.1
'@egjs/component': 3.0.5
'@egjs/imready': 1.4.1
'@egjs/imready@1.4.1':
dependencies:
'@cfcs/core': 0.0.24
'@egjs/component': 3.0.5
'@egjs/infinitegrid@4.12.0':
dependencies:
'@cfcs/core': 0.0.5
'@egjs/children-differ': 1.0.1
'@egjs/component': 3.0.5
'@egjs/grid': 1.16.0
'@egjs/list-differ': 1.0.1
'@egjs/list-differ@1.0.1': {}
'@egjs/react-infinitegrid@4.12.0':
dependencies:
'@egjs/infinitegrid': 4.12.0
'@egoist/tailwindcss-icons@1.8.1(tailwindcss@3.4.7)':
dependencies:
'@iconify/utils': 2.1.25
@ -14438,6 +14516,11 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.3
use-context-selector@2.0.0(react@18.3.1)(scheduler@0.23.2):
dependencies:
react: 18.3.1
scheduler: 0.23.2
use-isomorphic-layout-effect@1.1.2(@types/react@18.3.3)(react@18.3.1):
dependencies:
react: 18.3.1

View File

@ -3231,6 +3231,8 @@ declare const _routes: hono_hono_base.HonoBase<Env, {
type: "photo" | "video";
url: string;
preview_image_url?: string | undefined;
height?: number | undefined;
width?: number | undefined;
}[] | null | undefined;
attachments?: {
url: string;

View File

@ -26,6 +26,9 @@ const createDefaultSettings = (): UISettings => ({
// Content
readerRenderInlineStyle: false,
codeHighlightTheme: "github-dark",
// View
pictureViewMasonry: true,
})
export const {

View File

@ -0,0 +1,47 @@
import { memo, useState } from "react"
import { useInView } from "react-intersection-observer"
type ImpressionProps = {
event: string
onTrack?: () => any
properties?: Record<string, any>
}
export const ImpressionView: Component<
{ shouldTrack?: boolean } & ImpressionProps
> = (props) => {
const { shouldTrack = true, ...rest } = props
if (!shouldTrack) {
return <>{props.children}</>
}
return <ImpressionViewImpl {...rest} />
}
const ImpressionViewImpl: Component<ImpressionProps> = memo((props) => {
const [impression, setImpression] = useState(false)
const { ref } = useInView({
initialInView: false,
triggerOnce: true,
onChange(inView) {
if (!inView) {
return
}
setImpression(true)
window.posthog?.capture(props.event, {
impression: 1,
...props.properties,
})
props.onTrack?.()
},
})
return (
<>
{props.children}
{!impression && <span ref={ref} />}
</>
)
})
ImpressionViewImpl.displayName = "ImpressionView"

View File

@ -29,7 +29,7 @@ export const PageErrorFallback: FC<AppErrorFallbackProps> = (props) => {
<div className="center gap-4">
<StyledButton onClick={() => props.resetError()} variant="primary">
Reset
Retry
</StyledButton>
<StyledButton

View File

@ -94,7 +94,7 @@ export function FeedIcon({
} as any
}
className={cn(
"mr-2 flex shrink-0 items-center justify-center rounded-sm text-xs font-medium",
"mr-2 flex shrink-0 items-baseline justify-center rounded-sm text-xs font-medium",
"bg-[var(--fo-light-background)] text-white dark:bg-[var(--fo-dark-background)] dark:text-black",
className,
)}

View File

@ -2,6 +2,7 @@ import { tipcClient } from "@renderer/lib/client"
import { getProxyUrl } from "@renderer/lib/img-proxy"
import { showNativeMenu } from "@renderer/lib/native-menu"
import { cn } from "@renderer/lib/utils"
import { saveImageDimensionsToDb } from "@renderer/store/image/db"
import type { FC, ImgHTMLAttributes, VideoHTMLAttributes } from "react"
import { memo, useMemo, useState } from "react"
import { toast } from "sonner"
@ -25,6 +26,7 @@ export type MediaProps = BaseProps &
popper?: boolean
type: "photo"
previewImageUrl?: string
cacheDimensions?: boolean
})
| (VideoHTMLAttributes<HTMLVideoElement> & {
proxy?: {
@ -82,6 +84,18 @@ const MediaImpl: FC<MediaProps> = ({
}
props.onClick?.(e as any)
})
const handleOnLoad: React.ReactEventHandler<HTMLImageElement> =
useEventCallback((e) => {
rest.onLoad?.(e as any)
if ("cacheDimensions" in props && props.cacheDimensions && src) {
saveImageDimensionsToDb(src, {
src,
width: e.currentTarget.naturalWidth,
height: e.currentTarget.naturalHeight,
ratio: e.currentTarget.naturalWidth / e.currentTarget.naturalHeight,
})
}
})
const InnerContent = useMemo(() => {
switch (type) {
@ -98,6 +112,7 @@ const MediaImpl: FC<MediaProps> = ({
mediaContainerClassName,
)}
src={imgSrc}
onLoad={handleOnLoad}
onClick={handleClick}
{...(!disableContextMenu ?
{
@ -171,6 +186,7 @@ const MediaImpl: FC<MediaProps> = ({
disableContextMenu,
errorHandle,
handleClick,
handleOnLoad,
hidden,
imgSrc,
mediaContainerClassName,

View File

@ -37,6 +37,13 @@ const Wrapper: Component<{
className="absolute bottom-4 right-4 flex gap-3"
onClick={stopPropagation}
>
<button
onClick={dismiss}
className="center fixed right-6 top-6 size-8 rounded-full border bg-theme-background text-foreground/60"
type="button"
>
<i className="i-mgc-close-cute-re" />
</button>
{!!window.electron && (
<ActionButton
tooltip="Download"

View File

@ -13,23 +13,34 @@ import { Swiper, SwiperSlide } from "swiper/react"
import styles from "./index.module.css"
const defaultProxySize = {
width: 600,
height: 0,
}
export function SwipeMedia({
media,
uniqueKey,
className,
imgClassName,
onPreview,
proxySize = defaultProxySize,
forceSwiper,
}: {
media?: MediaModel[] | null
uniqueKey?: string
className?: string
imgClassName?: string
onPreview?: (media: MediaModel[], index?: number) => void
proxySize?: {
width: number
height: number
}
forceSwiper?: boolean
}) {
const uniqMedia = uniqBy(media, "url")
const hoverRef = useRef<HTMLDivElement>(null)
const [enableSwipe, setEnableSwipe] = useState(false)
const [enableSwipe, setEnableSwipe] = useState(!!forceSwiper)
useHover(
(event) => {
if (event.active) {
@ -38,6 +49,7 @@ export function SwipeMedia({
},
{
target: hoverRef,
enabled: !forceSwiper,
},
)
@ -73,14 +85,12 @@ export function SwipeMedia({
<Media
className={cn(imgClassName, "size-full rounded-none")}
alt="cover"
cacheDimensions={med.type === "photo"}
src={med.url}
type={med.type}
previewImageUrl={med.preview_image_url}
loading="lazy"
proxy={{
width: 600,
height: 600,
}}
proxy={proxySize}
disableContextMenu
onClick={(e) => {
e.stopPropagation()
@ -112,16 +122,14 @@ export function SwipeMedia({
e.stopPropagation()
onPreview?.(uniqMedia)
}}
cacheDimensions={uniqMedia[0].type === "photo"}
className="size-full rounded-none object-cover sm:transition-transform sm:duration-300 sm:ease-in-out sm:group-hover:scale-105"
alt="cover"
src={uniqMedia[0].url}
type={uniqMedia[0].type}
previewImageUrl={uniqMedia[0].preview_image_url}
loading="lazy"
proxy={{
width: 600,
height: 600,
}}
proxy={proxySize}
disableContextMenu
/>
) :

View File

@ -3,6 +3,7 @@ import { stopPropagation } from "@renderer/lib/dom"
import { cn } from "@renderer/lib/utils"
import * as React from "react"
import { ScrollElementContext } from "./ctx"
import styles from "./index.module.css"
const Corner = React.forwardRef<
@ -146,20 +147,29 @@ export const ScrollArea = React.forwardRef<
mask = false,
},
ref,
) => (
<Root className={rootClassName}>
<Viewport
ref={ref}
onWheel={stopPropagation}
className={cn(
flex ? "[&>div]:!flex [&>div]:!flex-col" : "",
viewportClassName,
)}
mask={mask}
>
{children}
</Viewport>
<Scrollbar className={scrollbarClassName} />
</Root>
),
) => {
const [viewportRef, setViewportRef] = React.useState<HTMLDivElement | null>(
null,
)
React.useImperativeHandle(ref, () => viewportRef as HTMLDivElement)
return (
<ScrollElementContext.Provider value={viewportRef}>
<Root className={rootClassName}>
<Viewport
ref={setViewportRef}
onWheel={stopPropagation}
className={cn(
flex ? "[&>div]:!flex [&>div]:!flex-col" : "",
viewportClassName,
)}
mask={mask}
>
{children}
</Viewport>
<Scrollbar className={scrollbarClassName} />
</Root>
</ScrollElementContext.Provider>
)
},
)

View File

@ -0,0 +1,3 @@
import { createContext } from "react"
export const ScrollElementContext = createContext<HTMLElement | null>(null)

View File

@ -1,8 +1,10 @@
import clsx from "clsx"
import { useIsomorphicLayoutEffect } from "foxact/use-isomorphic-layout-effect"
import { useCallback, useRef, useState } from "react"
import { useCallback, useContext, useRef, useState } from "react"
import { useEventCallback } from "usehooks-ts"
import { ScrollElementContext } from "./ctx"
const THRESHOLD = 0
export const useMaskScrollArea = <T extends HTMLElement = HTMLElement>({
ref,
@ -64,7 +66,9 @@ export const useMaskScrollArea = <T extends HTMLElement = HTMLElement>({
}, [eventHandler, getDomRef, element])
useIsomorphicLayoutEffect(() => {
if (!ref?.current) { return }
if (!ref?.current) {
return
}
const $ = ref.current
const resizeObserver = new ResizeObserver(() => {
eventHandler()
@ -92,3 +96,9 @@ export const useMaskScrollArea = <T extends HTMLElement = HTMLElement>({
"",
] as const
}
/**
* Get the scroll area element when in radix scroll area
* @returns
*/
export const useScrollViewElement = () => useContext(ScrollElementContext)

View File

@ -3,12 +3,13 @@ import { useLayoutEffect, useRef } from "react"
export const useRefValue = <S>(
value: S,
): Readonly<{
current: Readonly<S>
// eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
current: S extends Function ? S : Readonly<S>
}> => {
const ref = useRef<S>(value)
useLayoutEffect(() => {
ref.current = value
})
return ref
return ref as any
}

View File

@ -13,7 +13,7 @@ import {
import { useAuthQuery } from "@renderer/hooks/common"
import { apiClient } from "@renderer/lib/api-fetch"
import { defineQuery } from "@renderer/lib/defineQuery"
import { MarkAllButton } from "@renderer/modules/entry-column/mark-all-button"
import { MarkAllButton } from "@renderer/modules/entry-column/components/mark-all-button"
import type { FC } from "react"
import { useState } from "react"

View File

@ -1,7 +1,7 @@
import { Skeleton } from "@renderer/components/ui/skeleton"
import { ListItem } from "@renderer/modules/entry-column/list-item-template"
import { ListItem } from "@renderer/modules/entry-column/templates/list-item-template"
import type { UniversalItemProps } from "./types"
import type { UniversalItemProps } from "../types"
export function ArticleItem({
entryId,

View File

@ -1,7 +1,7 @@
import { Skeleton } from "@renderer/components/ui/skeleton"
import { ListItem } from "@renderer/modules/entry-column/list-item-template"
import { ListItem } from "@renderer/modules/entry-column/templates/list-item-template"
import type { UniversalItemProps } from "./types"
import type { UniversalItemProps } from "../types"
export function AudioItem({
entryId,

View File

@ -0,0 +1,36 @@
import { noop } from "foxact/noop"
import type { Dispatch, SetStateAction } from "react"
import { createContext, useCallback, useContext } from "react"
import {
createContext as createContextSelector,
useContextSelector,
} from "use-context-selector"
export const MasonryItemWidthContext = createContext(0)
export const useMasonryItemWidth = () => useContext(MasonryItemWidthContext)
export const MasonryItemsAspectRatioContext = createContextSelector(
{} as Record<string, number>,
)
export const useMasonryItemRatio = (url: string) =>
useContextSelector(MasonryItemsAspectRatioContext, (ctx) => ctx[url])
export const MasonryItemsAspectRatioSetterContext =
createContext<Dispatch<SetStateAction<Record<string, number>>>>(noop)
export const useSetStableMasonryItemRatio = () => {
const ctx = useContext(MasonryItemsAspectRatioSetterContext)
return useCallback(
(url: string, ratio: number) => {
ctx((prev) => {
// Skip if the ratio is already set, make it stable
if (prev[url]) return prev
return { ...prev, [url]: ratio }
})
},
[ctx],
)
}

View File

@ -1,7 +1,7 @@
import { Skeleton } from "@renderer/components/ui/skeleton"
import { ListItem } from "@renderer/modules/entry-column/list-item-template"
import { ListItem } from "@renderer/modules/entry-column/templates/list-item-template"
import type { UniversalItemProps } from "./types"
import type { UniversalItemProps } from "../types"
export function NotificationItem({
entryId,

View File

@ -0,0 +1,176 @@
import { SwipeMedia } from "@renderer/components/ui/media/swipe-media"
import { ReactVirtuosoItemPlaceholder } from "@renderer/components/ui/placeholder"
import { Skeleton } from "@renderer/components/ui/skeleton"
import { useRouteParamsSelector } from "@renderer/hooks/biz/useRouteParams"
import { FeedViewType } from "@renderer/lib/enum"
import { cn } from "@renderer/lib/utils"
import { useEntry } from "@renderer/store/entry/hooks"
import { useImageDimensions } from "@renderer/store/image"
import type { PropsWithChildren } from "react"
import { memo, useEffect, useState } from "react"
import { usePreviewMedia } from "../../../components/ui/media/hooks"
import { EntryItemWrapper } from "../layouts/EntryItemWrapper"
import { GridItem } from "../templates/grid-item-template"
import type { UniversalItemProps } from "../types"
import {
useMasonryItemRatio,
useMasonryItemWidth,
useSetStableMasonryItemRatio,
} from "./contexts/picture-masonry-context"
export function PictureItem({
entryId,
entryPreview,
translation,
}: UniversalItemProps) {
const entry = useEntry(entryId) || entryPreview
const isActive = useRouteParamsSelector(
({ entryId }) => entryId === entry?.entries.id,
)
const previewMedia = usePreviewMedia()
if (!entry) return <ReactVirtuosoItemPlaceholder />
return (
<GridItem
entryId={entryId}
entryPreview={entryPreview}
translation={translation}
>
<div className="relative flex gap-2 overflow-x-auto">
{entry.entries.media ? (
<SwipeMedia
media={entry.entries.media}
className={cn(
"aspect-square",
"w-full shrink-0 rounded-md",
isActive && "rounded-b-none",
)}
imgClassName="object-cover"
uniqueKey={entryId}
onPreview={(media, i) => {
previewMedia(media, i)
}}
/>
) : (
<div className="center aspect-square w-full flex-col gap-1 bg-muted text-xs text-muted-foreground">
<i className="i-mgc-sad-cute-re size-6" />
No media available
</div>
)}
</div>
</GridItem>
)
}
const proxySize = {
width: 600,
height: 0,
}
export const PictureWaterFallItem = memo(function PictureWaterFallItem({
entryId,
entryPreview,
translation,
}: UniversalItemProps) {
const entry = useEntry(entryId) || entryPreview
const isActive = useRouteParamsSelector(
({ entryId }) => entryId === entry?.entries.id,
)
const previewMedia = usePreviewMedia()
if (!entry) return null
return (
<EntryItemWrapper
view={FeedViewType.Pictures}
entry={entry}
itemClassName="group hover:bg-theme-item-hover"
>
<GridItem
wrapperClassName="p-0 h-full flex flex-col"
entryId={entryId}
entryPreview={entryPreview}
translation={translation}
>
{entry.entries.media ? (
<MasonryItemFixedDimensionWrapper url={entry.entries.media[0].url}>
<SwipeMedia
media={entry.entries.media}
className={cn(
"w-full shrink-0 grow rounded-md",
isActive && "rounded-b-none",
)}
proxySize={proxySize}
imgClassName="object-cover"
uniqueKey={entryId}
onPreview={(media, i) => {
previewMedia(media, i)
}}
/>
</MasonryItemFixedDimensionWrapper>
) : null}
</GridItem>
</EntryItemWrapper>
)
})
const MasonryItemFixedDimensionWrapper = (
props: PropsWithChildren<{
url: string
}>,
) => {
const { url, children } = props
const dim = useImageDimensions(url)
const itemWidth = useMasonryItemWidth()
const itemHeight = dim ? itemWidth / dim.ratio : itemWidth
const stableRadio = useState(() => itemWidth / itemHeight || 1)[0]
const setItemStableRatio = useSetStableMasonryItemRatio()
const stableRadioCtx = useMasonryItemRatio(url)
useEffect(() => {
setItemStableRatio(url, stableRadio)
}, [setItemStableRatio, stableRadio, url])
return (
<div
className="relative flex h-full gap-2 overflow-x-auto"
style={{
width: itemWidth,
height: itemWidth / stableRadioCtx,
}}
>
{children}
</div>
)
}
export const PictureItemSkeleton = (
<div className="relative max-w-md rounded-md bg-theme-background text-zinc-700 transition-colors dark:text-neutral-400">
<div className="relative">
<div className="p-1.5">
<div className="relative flex gap-2 overflow-x-auto">
<div className="relative flex aspect-square w-full shrink-0 items-center overflow-hidden rounded-md">
<Skeleton className="size-full overflow-hidden" />
</div>
</div>
<div className="relative flex-1 px-2 pb-3 pt-1 text-sm">
<div className="relative mb-1 mt-1.5 truncate font-medium leading-none">
<Skeleton className="h-4 w-3/4 " />
</div>
<div className="mt-1 flex items-center gap-1 truncate text-[13px]">
<Skeleton className="mr-0.5 size-4" />
<Skeleton className="h-3 w-1/2 " />
<span className="text-zinc-500">·</span>
<Skeleton className="h-3 w-12 " />
</div>
</div>
</div>
</div>
</div>
)

View File

@ -0,0 +1,212 @@
import { MasonryInfiniteGrid } from "@egjs/react-infinitegrid"
import { useScrollViewElement } from "@renderer/components/ui/scroll-area/hooks"
import { FeedViewType } from "@renderer/lib/enum"
import { getEntry } from "@renderer/store/entry"
import { imageActions } from "@renderer/store/image"
import { throttle } from "lodash-es"
import type { CSSProperties, FC, PropsWithChildren } from "react"
import {
memo,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react"
import { useEventCallback } from "usehooks-ts"
import { EntryItemSkeleton } from "../item"
import {
MasonryItemsAspectRatioContext,
MasonryItemsAspectRatioSetterContext,
MasonryItemWidthContext,
} from "./contexts/picture-masonry-context"
import { PictureWaterFallItem } from "./picture-item"
// grid grid-cols-1 @lg:grid-cols-2 @3xl:grid-cols-3 @6xl:grid-cols-4 @7xl:grid-cols-5 px-4 gap-1.5
const breakpoints = {
0: 1,
// 32rem => 32 * 16= 512
512: 2,
// 48rem => 48 * 16= 768
768: 3,
// 72rem => 72 * 16= 1152
1152: 4,
// 80rem => 80 * 16= 1280
1280: 5,
1536: 6,
1792: 7,
2048: 8,
}
const getCurrentColumn = (w: number) => {
// Initialize column count with the minimum number of columns
let columns = 1
// Iterate through each breakpoint and determine the column count
for (const [breakpoint, cols] of Object.entries(breakpoints)) {
if (w >= Number.parseInt(breakpoint)) {
columns = cols
} else {
break
}
}
return columns
}
export const PictureMasonry: FC<MasonryProps> = (props) => {
const { data } = props
const [isInit, setIsInit] = useState(false)
const restoreDimensions = useEventCallback(async () => {
const images = [] as string[]
data.forEach((entryId) => {
const entry = getEntry(entryId)
images.push(...imageActions.getImagesFromEntry(entry.entries))
})
return imageActions.fetchDimensionsFromDb(images)
})
useEffect(() => {
restoreDimensions().finally(() => {
setIsInit(true)
})
}, [])
if (!isInit) return null
return (
<PictureMasonryImpl {...props} restoreDimensions={restoreDimensions} />
)
}
interface MasonryProps {
data: string[]
endReached: () => Promise<any>
hasNextPage: boolean
}
const PictureMasonryImpl = ({
data,
endReached,
hasNextPage,
restoreDimensions,
}: MasonryProps & { restoreDimensions: () => Promise<void> }) => {
const masonryRef = useRef<MasonryInfiniteGrid>(null)
const containerRef = useRef<HTMLDivElement>(null)
const [currentItemWidth, setCurrentItemWidth] = useState(0)
const [currentColumn, setCurrentColumn] = useState(1)
const $scroll = useScrollViewElement()
useLayoutEffect(() => {
const $warpper = containerRef.current
if (!$warpper) return
const recal = throttle(() => {
const column = getCurrentColumn($warpper.clientWidth)
setCurrentItemWidth(Math.trunc($warpper.clientWidth / column - 20))
setCurrentColumn(column)
masonryRef.current?.renderItems()
}, 50)
let previousWidth = $warpper.offsetWidth
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const newWidth = entry.contentRect.width
if (newWidth !== previousWidth) {
previousWidth = newWidth
recal()
}
}
})
recal()
resizeObserver.observe($warpper)
return () => {
resizeObserver.disconnect()
}
}, [])
const itemStyle: CSSProperties = useMemo(
() => ({ width: currentItemWidth }),
[currentItemWidth],
)
const [masonryItemsRadio, setMasonryItemsRadio] = useState<
Record<string, number>
>({})
return (
<div ref={containerRef} className="p-4">
<MasonryItemsAspectRatioContext.Provider value={masonryItemsRadio}>
<MasonryItemWidthContext.Provider value={currentItemWidth}>
<MasonryItemsAspectRatioSetterContext.Provider
value={setMasonryItemsRadio}
>
<MasonryInfiniteGrid
ref={masonryRef}
placeholder={<LoadingSkeletonItem itemStyle={itemStyle} />}
onRequestAppend={(e) => {
if (!hasNextPage) return
e.wait()
const nextGroupKey = (+e.groupKey! || 0) + 1
e.currentTarget.appendPlaceholders(10, nextGroupKey)
endReached()
.then(restoreDimensions).finally(() => {
e.ready()
})
}}
scrollContainer={$scroll}
observeChildren
useFirstRender
gap={{ vertical: 24 }}
column={currentColumn}
>
{data.map((entryId, index) => (
<ItemWrapper
data-grid-groupkey={index}
itemStyle={itemStyle}
key={entryId}
entryId={entryId}
>
<PictureWaterFallItem entryId={entryId} />
</ItemWrapper>
))}
</MasonryInfiniteGrid>
</MasonryItemsAspectRatioSetterContext.Provider>
</MasonryItemWidthContext.Provider>
</MasonryItemsAspectRatioContext.Provider>
</div>
)
}
const LoadingSkeletonItem = ({ itemStyle }: { itemStyle: CSSProperties }) => (
<div style={itemStyle}>
<EntryItemSkeleton single view={FeedViewType.Pictures} />
</div>
)
const ItemWrapperImpl = ({
itemStyle,
style,
children,
entryId,
...rest
}: {
itemStyle: CSSProperties
entryId: string
} & React.HtmlHTMLAttributes<HTMLDivElement> &
PropsWithChildren) => (
<div
style={{
...style,
...itemStyle,
}}
{...rest}
>
{children}
</div>
)
const ItemWrapper = memo(ItemWrapperImpl)

View File

@ -13,11 +13,11 @@ import { cn } from "@renderer/lib/utils"
import { useEntry } from "@renderer/store/entry/hooks"
import { useFeedById } from "@renderer/store/feed"
import { ReactVirtuosoItemPlaceholder } from "../../components/ui/placeholder"
import { MarkAllButton } from "./mark-all-button"
import { StarIcon } from "./star-icon"
import { EntryTranslation } from "./translation"
import type { EntryListItemFC } from "./types"
import { ReactVirtuosoItemPlaceholder } from "../../../components/ui/placeholder"
import { MarkAllButton } from "../components/mark-all-button"
import { StarIcon } from "../star-icon"
import { EntryTranslation } from "../translation"
import type { EntryListItemFC } from "../types"
export const SocialMediaItem: EntryListItemFC = ({
entryId,

View File

@ -8,13 +8,13 @@ import { Skeleton } from "@renderer/components/ui/skeleton"
import { useRouteParamsSelector } from "@renderer/hooks/biz/useRouteParams"
import { urlToIframe } from "@renderer/lib/url-to-iframe"
import { cn } from "@renderer/lib/utils"
import { GridItem } from "@renderer/modules/entry-column/grid-item-template"
import { useEntry } from "@renderer/store/entry/hooks"
import { useHover } from "@use-gesture/react"
import { useEffect, useMemo, useRef, useState } from "react"
import { ReactVirtuosoItemPlaceholder } from "../../components/ui/placeholder"
import type { UniversalItemProps } from "./types"
import { ReactVirtuosoItemPlaceholder } from "../../../components/ui/placeholder"
import { GridItem } from "../templates/grid-item-template"
import type { UniversalItemProps } from "../types"
const ViewTag = window.electron ? "webview" : "iframe"

View File

@ -3,8 +3,8 @@ import { FeedViewType } from "@renderer/lib/enum"
import { cn } from "@renderer/lib/utils"
import { memo } from "react"
import { SocialMediaDateItem } from "../Items/social-media-item"
import { MarkAllButton } from "./mark-all-button"
import { SocialMediaDateItem } from "./social-media-item"
export const DateItem = memo(
({

View File

@ -1,3 +1,4 @@
import { useUISettingKey } from "@renderer/atoms/settings/ui"
import { m } from "@renderer/components/common/Motion"
import { FeedFoundCanBeFollowError } from "@renderer/components/errors/FeedFoundCanBeFollowErrorFallback"
import { FeedNotFound } from "@renderer/components/errors/FeedNotFound"
@ -14,24 +15,25 @@ import {
useRouteParamsSelector,
useRouteParms,
} from "@renderer/hooks/biz/useRouteParams"
import { useTitle } from "@renderer/hooks/common"
import { FeedViewType } from "@renderer/lib/enum"
import { cn, isBizId } from "@renderer/lib/utils"
import { useFeed } from "@renderer/queries/feed"
import { entryActions, useEntry } from "@renderer/store/entry"
import { useFeedByIdSelector } from "@renderer/store/feed"
import { useCallback, useEffect, useRef } from "react"
import type {
ScrollSeekConfiguration,
VirtuosoGridProps,
VirtuosoHandle,
VirtuosoProps,
} from "react-virtuoso"
import { VirtuosoGrid } from "react-virtuoso"
import { EntryListHeader } from "./EntryListHeader"
import { useEntriesByView, useEntryMarkReadHandler } from "./hooks"
import {
EntryItem,
EntryItemSkeleton,
EntryItemSkeletonWithDelayShow,
} from "./item"
import { EntryItem, EntryItemSkeleton } from "./item"
import { PictureMasonry } from "./Items/picture-masonry"
import { EntryListHeader } from "./layouts/EntryListHeader"
import { EntryEmptyList, EntryList, EntryListContent } from "./lists"
import { girdClassNames } from "./styles"
@ -58,6 +60,8 @@ export function EntryColumn() {
isCollection,
} = useRouteParms()
const activeEntry = useEntry(activeEntryId)
const feedTitle = useFeedByIdSelector(routeFeedId, (feed) => feed?.title)
useTitle(feedTitle)
useEffect(() => {
if (!activeEntryId) return
@ -80,7 +84,7 @@ export function EntryColumn() {
List: EntryListContent,
Footer: useCallback(() => {
if (!isFetchingNextPage) return null
return <EntryItemSkeletonWithDelayShow view={view} />
return <EntryItemSkeleton view={view} />
}, [isFetchingNextPage, view]),
ScrollSeekPlaceholder: useCallback(
() => <EntryItemSkeleton view={view} single />,
@ -96,11 +100,11 @@ export function EntryColumn() {
customScrollParent: scrollRef.current!,
totalCount: entries.totalCount,
endReached: () => {
endReached: useCallback(async () => {
if (!entries.isFetchingNextPage && entries.hasNextPage) {
entries.fetchNextPage()
await entries.fetchNextPage()
}
},
}, [entries]),
data: entriesIds,
onScroll: () => {
if (!isInteracted.current) {
@ -172,10 +176,9 @@ export function EntryColumn() {
)
) : view && views[view].gridMode ?
(
<VirtuosoGrid
listClassName={girdClassNames}
{...virtuosoOptions}
ref={virtuosoRef}
<ListGird
virtuosoOptions={virtuosoOptions}
virtuosoRef={virtuosoRef}
/>
) :
(
@ -192,11 +195,48 @@ export function EntryColumn() {
)
}
const ListGird = ({
virtuosoOptions,
virtuosoRef,
}: {
virtuosoOptions: Omit<
VirtuosoGridProps<string, unknown>,
"data" | "endReached"
> & {
data: string[]
endReached: () => Promise<any>
}
virtuosoRef: React.RefObject<VirtuosoHandle>
}) => {
const masonry = useUISettingKey("pictureViewMasonry")
const view = useRouteParamsSelector((s) => s.view)
const feedId = useRouteParamsSelector((s) => s.feedId)
if (masonry && view === FeedViewType.Pictures) {
return (
<PictureMasonry
key={feedId}
hasNextPage={virtuosoOptions.totalCount! > virtuosoOptions.data.length}
endReached={virtuosoOptions.endReached}
data={virtuosoOptions.data}
/>
)
}
return (
<VirtuosoGrid
listClassName={girdClassNames}
{...virtuosoOptions}
ref={virtuosoRef}
/>
)
}
const AddFeedHelper = () => {
const feedId = useRouteParamsSelector((s) => s.feedId)
const feedQuery = useFeed({ id: feedId })
if (!feedId) { return }
if (!feedId) {
return
}
if (feedId === FEED_COLLECTION_LIST || feedId === ROUTE_FEED_PENDING) {
return null
}

View File

@ -1,36 +1,27 @@
import { useGeneralSettingKey } from "@renderer/atoms/settings/general"
import { ListItemHoverOverlay } from "@renderer/components/ui/list-item-hover-overlay"
import { LoadingCircle } from "@renderer/components/ui/loading"
import { views } from "@renderer/constants"
import { useAsRead } from "@renderer/hooks/biz/useAsRead"
import { useEntryActions } from "@renderer/hooks/biz/useEntryActions"
import { useFeedActions } from "@renderer/hooks/biz/useFeedActions"
import { useNavigateEntry } from "@renderer/hooks/biz/useNavigateEntry"
import { useRouteParamsSelector } from "@renderer/hooks/biz/useRouteParams"
import { useAuthQuery } from "@renderer/hooks/common"
import { FeedViewType } from "@renderer/lib/enum"
import { showNativeMenu } from "@renderer/lib/native-menu"
import { cn } from "@renderer/lib/utils"
import { Queries } from "@renderer/queries"
import type { FlatEntryModel } from "@renderer/store/entry"
import { entryActions } from "@renderer/store/entry"
import { useEntry } from "@renderer/store/entry/hooks"
import type { FC, ReactNode } from "react"
import { memo, useCallback } from "react"
import { useDebounceCallback } from "usehooks-ts"
import { memo } from "react"
import { ReactVirtuosoItemPlaceholder } from "../../components/ui/placeholder"
import { ArticleItem, ArticleItemSkeleton } from "./article-item"
import { AudioItem, AudioItemSkeleton } from "./audio-item"
import { ArticleItem, ArticleItemSkeleton } from "./Items/article-item"
import { AudioItem, AudioItemSkeleton } from "./Items/audio-item"
import {
NotificationItem,
NotificationItemSkeleton,
} from "./notification-item"
import { PictureItem, PictureItemSkeleton } from "./picture-item"
import { SocialMediaItem, SocialMediaItemSkeleton } from "./social-media-item"
} from "./Items/notification-item"
import { PictureItem, PictureItemSkeleton } from "./Items/picture-item"
import { SocialMediaItem, SocialMediaItemSkeleton } from "./Items/social-media-item"
import { VideoItem, VideoItemSkeleton } from "./Items/video-item"
import { EntryItemWrapper } from "./layouts/EntryItemWrapper"
import { girdClassNames } from "./styles"
import type { EntryListItemFC } from "./types"
import { VideoItem, VideoItemSkeleton } from "./video-item"
interface EntryItemProps {
entryId: string
@ -43,17 +34,6 @@ function EntryItemImpl({
entry: FlatEntryModel
view?: number
}) {
const { items } = useEntryActions({
view,
entry,
})
const { items: feedItems } = useFeedActions({
feedId: entry.feedId,
view,
type: "entryList",
})
const translation = useAuthQuery(
Queries.ai.translation({
entry,
@ -70,26 +50,6 @@ function EntryItemImpl({
},
)
const isActive = useRouteParamsSelector(
({ entryId }) => entryId === entry.entries.id,
)
const asRead = useAsRead(entry)
const hoverMarkUnread = useGeneralSettingKey("hoverMarkUnread")
const handleMouseEnter = useDebounceCallback(
() => {
if (!hoverMarkUnread) return
if (!document.hasFocus()) return
if (asRead) return
entryActions.markRead(entry.feedId, entry.entries.id, true)
},
233,
{
leading: false,
},
)
let Item: EntryListItemFC
switch (view) {
@ -122,82 +82,15 @@ function EntryItemImpl({
}
}
const navigate = useNavigateEntry()
const handleClick: React.MouseEventHandler<HTMLDivElement> = useCallback(
(e) => {
e.stopPropagation()
navigate({
entryId: entry.entries.id,
})
},
[entry.entries.id, navigate],
)
const handleDoubleClick: React.MouseEventHandler<HTMLDivElement> =
useCallback(
() => entry.entries.url && window.open(entry.entries.url, "_blank"),
[entry.entries.url],
)
const handleContextMenu: React.MouseEventHandler<HTMLDivElement> =
useCallback(
(e) => {
e.preventDefault()
showNativeMenu(
[
...items
.filter((item) => !item.hide)
.map((item) => ({
type: "text" as const,
label: item.name,
click: item.onClick,
shortcut: item.shortcut,
})),
{
type: "separator" as const,
},
...feedItems.filter((item) => !item.disabled),
{
type: "separator" as const,
},
{
type: "text" as const,
label: "Copy Entry ID",
click: () => {
navigator.clipboard.writeText(entry.entries.id)
},
},
],
e,
)
},
[items, feedItems, entry.entries.id],
)
return (
<div data-entry-id={entry.entries.id}>
<div
className={cn(
"relative rounded-md bg-theme-background transition-colors",
asRead ?
"text-zinc-700 dark:text-neutral-400" :
"text-zinc-900 dark:text-neutral-300",
Item.wrapperClassName,
)}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onDoubleClick={handleDoubleClick}
onContextMenu={handleContextMenu}
>
<ListItemHoverOverlay
className={cn(views[view || 0].gridMode ? "inset-0" : "inset-y-1")}
isActive={isActive}
>
<Item entryId={entry.entries.id} translation={translation.data} />
</ListItemHoverOverlay>
</div>
</div>
<EntryItemWrapper
itemClassName={Item.wrapperClassName}
entry={entry}
view={view}
overlay
>
<Item entryId={entry.entries.id} translation={translation.data} />
</EntryItemWrapper>
)
}
@ -251,21 +144,3 @@ const createSkeletonItems = (element: ReactNode) => {
}
return children
}
export const EntryItemSkeletonWithDelayShow: FC<{
view: FeedViewType
}> = memo(({ view }) => {
const SkeletonItem = SkeletonItemMap[view]
return SkeletonItem ?
(
<div
className={cn(views[view].gridMode ? girdClassNames : "flex flex-col")}
>
{createSkeletonItems(SkeletonItem)}
</div>
) :
(
LoadingCircleFallback
)
})

View File

@ -0,0 +1,138 @@
import { useGeneralSettingKey } from "@renderer/atoms/settings/general"
import { ListItemHoverOverlay } from "@renderer/components/ui/list-item-hover-overlay"
import { views } from "@renderer/constants"
import { useAsRead } from "@renderer/hooks/biz/useAsRead"
import { useEntryActions } from "@renderer/hooks/biz/useEntryActions"
import { useFeedActions } from "@renderer/hooks/biz/useFeedActions"
import { useNavigateEntry } from "@renderer/hooks/biz/useNavigateEntry"
import { useRouteParamsSelector } from "@renderer/hooks/biz/useRouteParams"
import { showNativeMenu } from "@renderer/lib/native-menu"
import { cn } from "@renderer/lib/utils"
import type { FlatEntryModel } from "@renderer/store/entry"
import { entryActions } from "@renderer/store/entry"
import type { FC, PropsWithChildren } from "react"
import { useCallback } from "react"
import { useDebounceCallback } from "usehooks-ts"
export const EntryItemWrapper: FC<
{
entry: FlatEntryModel
view?: number
itemClassName?: string
overlay?: boolean
} & PropsWithChildren
> = ({ entry, view, overlay, children, itemClassName }) => {
const { items } = useEntryActions({
view,
entry,
})
const { items: feedItems } = useFeedActions({
feedId: entry.feedId,
view,
type: "entryList",
})
const isActive = useRouteParamsSelector(
({ entryId }) => entryId === entry.entries.id,
)
const asRead = useAsRead(entry)
const hoverMarkUnread = useGeneralSettingKey("hoverMarkUnread")
const handleMouseEnter = useDebounceCallback(
() => {
if (!hoverMarkUnread) return
if (!document.hasFocus()) return
if (asRead) return
entryActions.markRead(entry.feedId, entry.entries.id, true)
},
233,
{
leading: false,
},
)
const navigate = useNavigateEntry()
const handleClick: React.MouseEventHandler<HTMLDivElement> = useCallback(
(e) => {
e.stopPropagation()
navigate({
entryId: entry.entries.id,
})
},
[entry.entries.id, navigate],
)
const handleDoubleClick: React.MouseEventHandler<HTMLDivElement> =
useCallback(
() => entry.entries.url && window.open(entry.entries.url, "_blank"),
[entry.entries.url],
)
const handleContextMenu: React.MouseEventHandler<HTMLDivElement> =
useCallback(
(e) => {
e.preventDefault()
showNativeMenu(
[
...items
.filter((item) => !item.hide)
.map((item) => ({
type: "text" as const,
label: item.name,
click: item.onClick,
shortcut: item.shortcut,
})),
{
type: "separator" as const,
},
...feedItems.filter((item) => !item.disabled),
{
type: "separator" as const,
},
{
type: "text" as const,
label: "Copy Entry ID",
click: () => {
navigator.clipboard.writeText(entry.entries.id)
},
},
],
e,
)
},
[items, feedItems, entry.entries.id],
)
return (
<div data-entry-id={entry.entries.id}>
<div
className={cn(
"relative rounded-md bg-theme-background transition-colors",
asRead ?
"text-zinc-700 dark:text-neutral-400" :
"text-zinc-900 dark:text-neutral-300",
itemClassName,
)}
onClick={handleClick}
onMouseEnter={handleMouseEnter}
onDoubleClick={handleDoubleClick}
onContextMenu={handleContextMenu}
>
{
overlay ? (
<ListItemHoverOverlay
className={cn(views[view || 0].gridMode ? "inset-0" : "inset-y-1")}
isActive={isActive}
>
{children}
</ListItemHoverOverlay>
) : children
}
</div>
</div>
)
}

View File

@ -2,7 +2,9 @@ import {
setGeneralSetting,
useGeneralSettingKey,
} from "@renderer/atoms/settings/general"
import { setUISetting, useUISettingKey } from "@renderer/atoms/settings/ui"
import { useWhoami } from "@renderer/atoms/user"
import { ImpressionView } from "@renderer/components/common/ImpressionTracker"
import { ActionButton } from "@renderer/components/ui/button"
import { DividerVertical } from "@renderer/components/ui/divider"
import { EllipsisHorizontalTextWithTooltip } from "@renderer/components/ui/typography"
@ -12,19 +14,17 @@ import {
views,
} from "@renderer/constants"
import { shortcuts } from "@renderer/constants/shortcuts"
import {
useRouteParms,
} from "@renderer/hooks/biz/useRouteParams"
import { useRouteParms } from "@renderer/hooks/biz/useRouteParams"
import { useIsOnline } from "@renderer/hooks/common"
import { FeedViewType } from "@renderer/lib/enum"
import { cn, getOS, isBizId } from "@renderer/lib/utils"
import { useAIDailyReportModal } from "@renderer/modules/ai/ai-daily/hooks"
import { EntryHeader } from "@renderer/modules/entry-content/header"
import { useRefreshFeedMutation } from "@renderer/queries/feed"
import { useFeedById, useFeedHeaderTitle } from "@renderer/store/feed"
import type { FC } from "react"
import { useAIDailyReportModal } from "../ai/ai-daily/hooks"
import { EntryHeader } from "../entry-content/header"
import { MarkAllButton } from "./mark-all-button"
import { MarkAllButton } from "../components/mark-all-button"
export const EntryListHeader: FC<{
totalCount: number
@ -113,6 +113,7 @@ export const EntryListHeader: FC<{
)}
{view === FeedViewType.SocialMedia && <DailyReportButton />}
{view === FeedViewType.Pictures && <SwitchToMasonryButton />}
{isOnline ? (
feed?.ownerUserId === user?.id && isBizId(routerParams.feedId!) ?
@ -170,11 +171,37 @@ export const EntryListHeader: FC<{
const DailyReportButton: FC = () => {
const present = useAIDailyReportModal()
return (
<ActionButton
onClick={present}
tooltip="Daily Report"
>
<ActionButton onClick={present} tooltip="Daily Report">
<i className="i-mgc-magic-2-cute-re" />
</ActionButton>
)
}
const SwitchToMasonryButton = () => {
const isMasonry = useUISettingKey("pictureViewMasonry")
return (
<ImpressionView
event="Switch to Masonry"
properties={{
masonry: isMasonry ? 1 : 0,
}}
>
<ActionButton
onClick={() => {
setUISetting("pictureViewMasonry", !isMasonry)
window.posthog?.capture("Switch to Masonry", {
masonry: !isMasonry ? 1 : 0,
click: 1,
})
}}
tooltip={`Switch to ${isMasonry ? "Grid" : "Masonry"}`}
>
<i
className={cn(
!isMasonry ? "i-mgc-grid-cute-re" : "i-mgc-grid-2-cute-re",
)}
/>
</ActionButton>
</ImpressionView>
)
}

View File

@ -10,7 +10,7 @@ import { forwardRef, memo, useCallback } from "react"
import type { VirtuosoHandle, VirtuosoProps } from "react-virtuoso"
import { GroupedVirtuoso, Virtuoso } from "react-virtuoso"
import { DateItem } from "./date-item"
import { DateItem } from "./components/DateItem"
import { EntryColumnShortcutHandler } from "./EntryColumnShortcutHandler"
export const EntryListContent = forwardRef<HTMLDivElement>((props, ref) => (

View File

@ -1,80 +0,0 @@
import { SwipeMedia } from "@renderer/components/ui/media/swipe-media"
import { ReactVirtuosoItemPlaceholder } from "@renderer/components/ui/placeholder"
import { Skeleton } from "@renderer/components/ui/skeleton"
import { useRouteParamsSelector } from "@renderer/hooks/biz/useRouteParams"
import { cn } from "@renderer/lib/utils"
import { GridItem } from "@renderer/modules/entry-column/grid-item-template"
import { useEntry } from "@renderer/store/entry/hooks"
import { usePreviewMedia } from "../../components/ui/media/hooks"
import type { UniversalItemProps } from "./types"
export function PictureItem({
entryId,
entryPreview,
translation,
}: UniversalItemProps) {
const entry = useEntry(entryId) || entryPreview
const isActive = useRouteParamsSelector(
({ entryId }) => entryId === entry?.entries.id,
)
const previewMedia = usePreviewMedia()
if (!entry) return <ReactVirtuosoItemPlaceholder />
return (
<GridItem
entryId={entryId}
entryPreview={entryPreview}
translation={translation}
>
<div className="relative flex gap-2 overflow-x-auto">
{entry.entries.media ? (
<SwipeMedia
media={entry.entries.media}
className={cn(
"aspect-square w-full shrink-0 rounded-md",
isActive && "rounded-b-none",
)}
imgClassName="object-cover"
uniqueKey={entryId}
onPreview={(media, i) => {
previewMedia(media, i)
}}
/>
) : (
<div className="center aspect-square w-full flex-col gap-1 bg-muted text-xs text-muted-foreground">
<i className="i-mgc-sad-cute-re size-6" />
No media available
</div>
)}
</div>
</GridItem>
)
}
export const PictureItemSkeleton = (
<div className="relative max-w-md rounded-md bg-theme-background text-zinc-700 transition-colors dark:text-neutral-400">
<div className="relative">
<div className="p-1.5">
<div className="relative flex gap-2 overflow-x-auto">
<div className="relative flex aspect-square w-full shrink-0 items-center overflow-hidden rounded-md">
<Skeleton className="size-full overflow-hidden" />
</div>
</div>
<div className="relative flex-1 px-2 pb-3 pt-1 text-sm">
<div className="relative mb-1 mt-1.5 truncate font-medium leading-none">
<Skeleton className="h-4 w-3/4 " />
</div>
<div className="mt-1 flex items-center gap-1 truncate text-[13px]">
<Skeleton className="mr-0.5 size-4" />
<Skeleton className="h-3 w-1/2 " />
<span className="text-zinc-500">·</span>
<Skeleton className="h-3 w-12 " />
</div>
</div>
</div>
</div>
</div>
)

View File

@ -1,5 +0,0 @@
Mark as read has three logic now.
- Rendering entry as read.
- Hover entry as read
- Scrolling entry as read

View File

@ -8,16 +8,18 @@ import { useEntry } from "@renderer/store/entry/hooks"
import { useFeedById } from "@renderer/store/feed"
import dayjs from "dayjs"
import { StarIcon } from "./star-icon"
import type { UniversalItemProps } from "./types"
import { StarIcon } from "../star-icon"
import type { UniversalItemProps } from "../types"
export function GridItem({
entryId,
entryPreview,
translation,
children,
wrapperClassName,
}: UniversalItemProps & {
children?: React.ReactNode
wrapperClassName?: string
}) {
const entry = useEntry(entryId) || entryPreview
const feeds = useFeedById(entry?.feedId)
@ -26,11 +28,11 @@ export function GridItem({
if (!entry) return <ReactVirtuosoItemPlaceholder />
return (
<div className="p-1.5">
<div className={cn("p-1.5", wrapperClassName)}>
{children}
<div
className={cn(
"relative flex-1 px-2 py-1 text-sm",
"relative px-2 py-1 text-sm",
!asRead &&
"before:absolute before:-left-0.5 before:top-[14.5px] before:block before:size-1.5 before:rounded-full before:bg-theme-accent",
)}
@ -57,7 +59,7 @@ export function GridItem({
entry={entry.entries}
size={18}
/>
<span>{feeds?.title}</span>
<span className="min-w-0 truncate">{feeds?.title}</span>
<span className="text-zinc-500">·</span>
<span className="text-zinc-500">
{dayjs

View File

@ -10,9 +10,9 @@ import { EntryTranslation } from "@renderer/modules/entry-column/translation"
import { useEntry } from "@renderer/store/entry/hooks"
import { useFeedById } from "@renderer/store/feed"
import { ReactVirtuosoItemPlaceholder } from "../../components/ui/placeholder"
import { StarIcon } from "./star-icon"
import type { UniversalItemProps } from "./types"
import { ReactVirtuosoItemPlaceholder } from "../../../components/ui/placeholder"
import { StarIcon } from "../star-icon"
import type { UniversalItemProps } from "../types"
export function ListItem({
entryId,

View File

@ -102,6 +102,7 @@ export function FeedColumn({ children }: PropsWithChildren) {
spring.jump(-active * jotaiStore.get(carouselWidthAtom))
}
const dispose = jotaiStore.sub(carouselWidthAtom, handler)
spring.set(-active * jotaiStore.get(carouselWidthAtom))
return () => {
dispose()

View File

@ -7,12 +7,12 @@ import { views } from "@renderer/constants"
import { useTitle } from "@renderer/hooks/common"
import { FeedViewType } from "@renderer/lib/enum"
import { cn, pluralize } from "@renderer/lib/utils"
import { ArticleItem } from "@renderer/modules/entry-column/article-item"
import { NotificationItem } from "@renderer/modules/entry-column/notification-item"
import { PictureItem } from "@renderer/modules/entry-column/picture-item"
import { SocialMediaItem } from "@renderer/modules/entry-column/social-media-item"
import { ArticleItem } from "@renderer/modules/entry-column/Items/article-item"
import { NotificationItem } from "@renderer/modules/entry-column/Items/notification-item"
import { PictureItem } from "@renderer/modules/entry-column/Items/picture-item"
import { SocialMediaItem } from "@renderer/modules/entry-column/Items/social-media-item"
import { VideoItem } from "@renderer/modules/entry-column/Items/video-item"
import type { UniversalItemProps } from "@renderer/modules/entry-column/types"
import { VideoItem } from "@renderer/modules/entry-column/video-item"
import { useEntriesPreview } from "@renderer/queries/entries"
import { useFeed } from "@renderer/queries/feed"
import { DEEPLINK_SCHEME } from "@shared/constants"

View File

@ -4,7 +4,11 @@ import {
getEntriesParams,
omitObjectUndefinedValue,
} from "@renderer/lib/utils"
import type { CombinedEntryModel, EntryModel, FeedModel, UserModel,
import type {
CombinedEntryModel,
EntryModel,
FeedModel,
UserModel,
} from "@renderer/models"
import { EntryService } from "@renderer/services"
import { produce } from "immer"
@ -12,6 +16,7 @@ import { merge, omit } from "lodash-es"
import type { EntryReadHistoriesModel } from "src/hono"
import { feedActions } from "../feed"
import { imageActions } from "../image"
import { feedUnreadActions } from "../unread"
import { userActions } from "../user"
import { createZustandStore, doMutationAndTransaction } from "../utils/helper"
@ -121,10 +126,14 @@ class EntryActions {
)
}
patchManyByFeedId(feedId: string, changed: Partial<CombinedEntryModel>, filter?: {
startTime: number
endTime: number
}) {
patchManyByFeedId(
feedId: string,
changed: Partial<CombinedEntryModel>,
filter?: {
startTime: number
endTime: number
},
) {
set((state) =>
produce(state, (draft) => {
const ids = draft.entries[feedId]
@ -157,17 +166,6 @@ class EntryActions {
feedUnreadActions.updateByFeedId(feedId, 0)
}
private patchAll(changed: Partial<CombinedEntryModel>) {
set((state) =>
produce(state, (draft) => {
for (const entry of Object.values(draft.flatMapEntries)) {
Object.assign(entry, changed)
}
return draft
}),
)
}
upsertMany(data: CombinedEntryModel[]) {
const feeds = [] as FeedModel[]
const entries = [] as EntryModel[]
@ -224,6 +222,20 @@ class EntryActions {
if (item.collections) {
entryCollection[item.entries.id] = item.collections
}
if (item.entries.media) {
for (const media of item.entries.media) {
if (!media.height || !media.width) continue
imageActions.saveImages([
{
src: media.url,
width: media.width,
height: media.height,
ratio: media.width / media.height,
},
])
}
}
}
return draft

View File

@ -0,0 +1,12 @@
import { createStore, get, set } from "idb-keyval"
import type { StoreImageType } from "."
const db = createStore("FOLLOW_IMAGE_DIMENSIONS", "image-dimensions")
export const getImageDimensionsFromDb = async (url: string) => await get(url, db)
export const saveImageDimensionsToDb = async (
url: string,
dimensions: StoreImageType,
) => await set(url, dimensions, db)

View File

@ -0,0 +1,66 @@
import type { EntryModel } from "@renderer/models"
import { createZustandStore } from "../utils/helper"
import { getImageDimensionsFromDb } from "./db"
export interface StoreImageType {
src: string
width: number
height: number
ratio: number
}
interface State {
images: Record<string, StoreImageType>
}
export const useImageStore = createZustandStore<State>("image")(() => ({
images: {},
}))
const set = useImageStore.setState
const get = useImageStore.getState
class ImageActions {
getImage(src: string) {
return get().images[src]
}
saveImages(images: StoreImageType[]) {
set((state) => {
const newImages = { ...state.images }
for (const image of images) {
newImages[image.src] = image
}
return { images: newImages }
})
}
async fetchDimensionsFromDb(images: string[]) {
const dims = [] as StoreImageType[]
for (const image of images) {
const dbData = await getImageDimensionsFromDb(image)
if (dbData) {
dims.push(dbData)
}
}
imageActions.saveImages(dims)
}
getImagesFromEntry(entry: EntryModel) {
const images = [] as string[]
if (!entry.media) return images
for (const media of entry.media) {
if (media.type === "photo") {
images.push(media.url)
}
}
return images
}
}
export const imageActions = new ImageActions()
/// // HOOKS
export const useImageDimensions = (url: string) =>
useImageStore((state) => state.images[url])
export const useImagesHasDimensions = (urls?: string[]) =>
useImageStore((state) =>
urls ? urls?.every((url) => state.images[url]) : false,
)

View File

@ -24,4 +24,7 @@ export interface UISettings {
readerFontFamily: string
readerRenderInlineStyle: boolean
codeHighlightTheme: string
// view
pictureViewMasonry: boolean
}

39
src/shared/src/queue.ts Normal file
View File

@ -0,0 +1,39 @@
export class AsyncQueue {
private maxConcurrent: number
private queue: (() => Promise<any>)[]
private activeCount: number
constructor(maxConcurrent: number) {
this.maxConcurrent = maxConcurrent
this.queue = []
this.activeCount = 0
}
private async runNext() {
if (this.activeCount >= this.maxConcurrent || this.queue.length === 0) {
return
}
this.activeCount++
const request = this.queue.shift()!
try {
return await request()
} catch (error) {
console.error("Request failed", error)
} finally {
this.activeCount--
this.runNext() // Start the next request after this one finishes
}
}
add(request: () => Promise<any>) {
this.queue.push(request)
return this.runNext()
}
addMultiple(requests: (() => Promise<any>)[]) {
this.queue.push(...requests)
this.runNext()
}
}