release(desktop): Release v0.4.2

release(desktop): Release v0.4.2
This commit is contained in:
DIYgod 2025-04-12 17:21:57 +08:00 committed by GitHub
commit bcf9dca64e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
321 changed files with 6972 additions and 9095 deletions

View File

@ -45,13 +45,10 @@ updates:
versions: [">0.1.2"]
open-pull-requests-limit: 100
groups:
minor:
minor-and-patch:
applies-to: version-updates
update-types:
- "minor"
patch:
applies-to: version-updates
update-types:
- "patch"
pathed:
patterns:

View File

@ -4,17 +4,13 @@ on:
push:
branches: [dev]
paths:
- "apps/mobile/web-app/**"
- "apps/mobile/native/**"
- "apps/mobile/package.json"
- "apps/mobile/app.config.ts"
- "apps/mobile/**"
- "pnpm-lock.yaml"
pull_request:
branches: [dev]
paths:
- "apps/mobile/web-app/**"
- "apps/mobile/native/**"
- "apps/mobile/package.json"
- "apps/mobile/app.config.ts"
- "apps/mobile/**"
- "pnpm-lock.yaml"
workflow_dispatch:
inputs:
profile:
@ -68,11 +64,20 @@ jobs:
- name: 🔨 Build Android app
working-directory: apps/mobile
run: eas build --platform android --profile ${{ github.event.inputs.profile || 'preview' }} --local --output=${{ github.workspace }}/build.apk
run: eas build --platform android --profile ${{ github.event.inputs.profile || 'preview' }} --local --output=${{ github.workspace }}/build.${{ github.event.inputs.profile == 'production' && 'aab' || 'apk' }}
- name: 📤 Upload apk Artifact
if: github.event.inputs.profile != 'production'
uses: actions/upload-artifact@v4
with:
name: app-android
path: ${{ github.workspace }}/build.apk
retention-days: 90
- name: 📤 Upload aab Artifact
if: github.event.inputs.profile == 'production'
uses: actions/upload-artifact@v4
with:
name: aab-android
path: ${{ github.workspace }}/build.aab
retention-days: 90

View File

@ -1,4 +1,4 @@
name: Build
name: Build Desktop
on:
push:

View File

@ -1,4 +1,4 @@
name: Build iOS IPA for development
name: Build iOS for development
on:
push:

View File

@ -1,4 +1,4 @@
name: Build iOS IPA
name: Build iOS
on:
push:

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
# Contributing to Follow
# Contributing to Folo
Thank you for considering contributing to Follow! We welcome contributions from the community to help improve and expand the project.
Thank you for considering contributing to Folo! We welcome contributions from the community to help improve and expand the project.
## Getting Started
@ -87,7 +87,7 @@ To develop in the mobile app, follow these steps:
cd apps/mobile
```
2. Build and install Follow(dev) app from source: (This step will take a while and only need to be done once)
2. Build and install Folo(dev) app from source: (This step will take a while and only need to be done once)
```sh
pnpm expo prebuild --clean # Optional
@ -113,7 +113,7 @@ To develop native iOS modules, follow these steps:
2. Open project in Xcode:
```sh
open Follow.xcworkspace
open Folo.xcworkspace
```
3. Open `Pods` in left sidebar and select `FollowNative`:
@ -140,4 +140,4 @@ We look forward to your contributions!
## License
By contributing to Follow, you agree that your contributions will be licensed under the GNU General Public License version 3, with the special exceptions noted in the `README.md`.
By contributing to Folo, you agree that your contributions will be licensed under the GNU General Public License version 3, with the special exceptions noted in the `README.md`.

View File

@ -0,0 +1,28 @@
# What's New in v0.4.2
## Shiny new things
- Added Cubox integration (#3385)
- Included document links on the actions and discover pages (acdca33)
## Improvements
- Optimized AI summary styles
- Redesigned the search card in Discover; now displaying feed update time and frequency more intuitively
- Revamped the header UI and interactions for a more concise, dynamic experience
- Removed the mark-as-read confirmation (04e8a48)
- Added pulse animation to the skeleton component (#3369)
- Refined the sorting logic for RSSHub instances (a824343)
- Enabled AI summary by default (8da8a71)
- Enhanced the entry sharing copy by including both title and description (#3462)
- Displayed text content within video modal preview (#3458)
## No longer broken
- Fixed incorrect translation results and formatting (55524b) (#3421)
- Restored the entry list header in the inbox
- Resolved issues with masonry layout column adjustments failing in certain cases (#3425)
- Hid the "open in browser" option when it is unavailable (ffada7c)
- Corrected the inbox unread count to ensure it is up to date (82baf0b)
- Prevented updates to the inbox unread count for read entries during deletion (c6024e3)
- Fixed an issue where videos were not displayed correctly at times (935e39b)

View File

@ -1,7 +1,7 @@
# What's new in vNEXT_VERSION
## New Features
## Shiny new things
## Improvements
## Bug Fixes
## No longer broken

View File

@ -1,13 +1,13 @@
{
"name": "Folo",
"type": "module",
"version": "0.4.1",
"version": "0.4.2",
"private": true,
"description": "Follow everything in one place",
"author": "Folo Team",
"homepage": "https://github.com/RSSNext",
"repository": {
"url": "https://github.com/RSSNext/follow",
"url": "https://github.com/RSSNext/Folo",
"type": "git"
},
"main": "./dist/main/index.js",
@ -54,7 +54,7 @@
"@follow/shared": "workspace:*",
"@follow/utils": "workspace:*",
"@pengx17/electron-forge-maker-appimage": "1.2.1",
"@sentry/vite-plugin": "3.2.4",
"@sentry/vite-plugin": "3.3.1",
"@types/html-minifier-terser": "7.0.2",
"@types/js-yaml": "4.0.9",
"@types/react-google-recaptcha": "2.1.9",
@ -65,7 +65,7 @@
"cssnano": "7.0.6",
"dotenv": "16.4.7",
"drizzle-orm": "0.41.0",
"electron": "34.4.1",
"electron": "34.5.0",
"electron-devtools-installer": "4.0.0",
"electron-packager-languages": "0.6.0",
"electron-vite": "3.1.0",
@ -83,7 +83,7 @@
"utf-8-validate": "6.0.5"
},
"productName": "Folo",
"mainHash": "039327ef2c09e66d8f5c4e021f33d78d4497794b222114fee795ed70c2045000",
"mainHash": "274b8ef4d0671d642c1350e1e4463fce6d677f81f77f59a666d6aa83f5866a22",
"bump": {
"before": [
"git pull --rebase",

View File

@ -29,7 +29,7 @@
"@follow/shared": "workspace:*",
"@follow/utils": "workspace:*",
"@openpanel/web": "1.0.1",
"@sentry/electron": "6.3.0",
"@sentry/electron": "6.4.0",
"builder-util-runtime": "9.3.1",
"cookie-es": "2.0.0",
"electron-context-menu": "4.0.5",
@ -55,8 +55,8 @@
"@follow/models": "workspace:*",
"@follow/types": "workspace:*",
"@types/js-yaml": "4.0.9",
"@types/node": "22.13.14",
"electron": "34.4.1",
"@types/node": "22.14.0",
"electron": "34.5.0",
"electron-devtools-installer": "4.0.0",
"hono": "4.7.5"
}

View File

@ -1,6 +1,7 @@
import type { Credentials } from "@eneris/push-receiver/dist/types"
import type { UserModel } from "@follow/models"
import { isLinux, isMacOS, isWindows } from "~/env"
import { logger } from "~/logger"
import { apiClient } from "./api-client"
@ -30,7 +31,7 @@ export const updateNotificationsToken = async (newCredentials?: Credentials) =>
await apiClient.messaging.$post({
json: {
token: credentials.fcm.token,
channel: "desktop",
channel: isMacOS ? "macos" : isWindows ? "windows" : isLinux ? "linux" : "desktop",
},
})
} catch (error) {

View File

@ -20,9 +20,9 @@
"@follow/shared": "workspace:*",
"@follow/tracker": "workspace:*",
"@fontsource/sn-pro": "5.2.5",
"@headlessui/react": "2.2.0",
"@headlessui/react": "2.2.1",
"@hookform/resolvers": "4.1.3",
"@lottiefiles/dotlottie-react": "0.13.2",
"@lottiefiles/dotlottie-react": "0.13.3",
"@openpanel/web": "1.0.1",
"@radix-ui/react-avatar": "1.1.3",
"@radix-ui/react-context-menu": "2.2.6",
@ -33,13 +33,13 @@
"@radix-ui/react-popover": "1.1.6",
"@radix-ui/react-slider": "1.2.3",
"@radix-ui/react-slot": "1.1.2",
"@sentry/react": "9.10.1",
"@sentry/react": "9.11.0",
"@shikijs/transformers": "3.2.1",
"@tanstack/query-sync-storage-persister": "5.70.0",
"@tanstack/react-query": "5.70.0",
"@tanstack/react-query-devtools": "5.70.0",
"@tanstack/react-query-persist-client": "5.70.0",
"@tanstack/react-virtual": "3.13.5",
"@tanstack/query-sync-storage-persister": "5.71.10",
"@tanstack/react-query": "5.71.10",
"@tanstack/react-query-devtools": "5.71.10",
"@tanstack/react-query-persist-client": "5.71.10",
"@tanstack/react-virtual": "3.13.6",
"@use-gesture/react": "10.3.1",
"@welldone-software/why-did-you-render": "10.0.1",
"@yornaath/batshit": "0.10.1",
@ -51,12 +51,12 @@
"dexie": "4.0.11",
"dexie-export-import": "4.1.4",
"dnum": "2.14.0",
"embla-carousel-react": "8.5.2",
"embla-carousel-wheel-gestures": "8.0.1",
"embla-carousel-react": "8.6.0",
"embla-carousel-wheel-gestures": "8.0.2",
"es-toolkit": "1.34.1",
"firebase": "11.5.0",
"firebase": "11.6.0",
"foxact": "0.2.45",
"framer-motion": "12.6.2",
"framer-motion": "12.6.3",
"franc-min": "6.2.0",
"fuse.js": "7.1.0",
"hast-util-to-jsx-runtime": "2.3.6",
@ -83,12 +83,12 @@
"react-ios-pwa-prompt": "2.0.6",
"react-qr-code": "2.0.15",
"react-resizable-layout": "npm:@innei/react-resizable-layout@0.7.3-fork.1",
"react-router": "7.4.1",
"react-router": "7.5.0",
"react-selecto": "1.26.3",
"react-shadow": "20.6.0",
"react-zoom-pan-pinch": "3.7.0",
"shiki": "3.2.1",
"sonner": "2.0.2",
"sonner": "2.0.3",
"tldts": "6.1.85",
"use-context-selector": "2.0.0",
"use-sync-external-store": "1.5.0",
@ -105,7 +105,7 @@
"@follow/models": "workspace:*",
"@follow/types": "workspace:*",
"@follow/utils": "workspace:*",
"@types/node": "22.13.14",
"@types/node": "22.14.0",
"@vite-pwa/assets-generator": "1.0.0",
"fake-indexeddb": "6.0.0",
"happy-dom": "17.4.4",

View File

@ -7,7 +7,8 @@ import { AnimatedCommandButton } from "./base"
export const CopyButton: Component<{
value: string
style?: React.CSSProperties
}> = ({ value, className, style }) => {
variant?: "solid" | "outline"
}> = ({ value, className, style, variant = "solid" }) => {
const copiedTimerRef = useRef<any>()
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(value)
@ -18,6 +19,7 @@ export const CopyButton: Component<{
<AnimatedCommandButton
className={className}
style={style}
variant={variant}
icon={<m.i className="i-mgc-copy-2-cute-re size-4" />}
onClick={handleCopy}
/>

View File

@ -9,6 +9,7 @@ import { cloneElement, useRef, useState } from "react"
interface AnimatedCommandButtonProps {
icon: React.JSX.Element
variant?: "solid" | "outline" | "ghost"
}
const iconVariants: Variants = {
@ -30,6 +31,7 @@ export const AnimatedCommandButton: FC<AnimatedCommandButtonProps & HTMLMotionPr
icon,
className,
style,
variant = "solid",
...props
}) => {
const [pressed, setPressed] = useState(false)
@ -40,7 +42,11 @@ export const AnimatedCommandButton: FC<AnimatedCommandButtonProps & HTMLMotionPr
type="button"
className={cn(
"center pointer-events-auto flex text-xs",
"border-accent/5 bg-accent/80 rounded-md border p-1.5 text-white backdrop-blur duration-200",
"rounded-md p-1.5 duration-200",
variant === "solid" || variant === "ghost"
? "border-accent/5 bg-accent/80 border text-white backdrop-blur"
: "text-accent bg-slate-50/50 dark:bg-slate-900/50",
variant === "ghost" && "bg-theme-item-active hover:bg-theme-item-hover",
className,
)}
onClick={useTypeScriptHappyCallback(

View File

@ -196,9 +196,10 @@ const ShikiCode: FC<
>
<div dangerouslySetInnerHTML={{ __html: rendered }} data-language={language} />
<CopyButton
variant="outline"
value={code}
style={{
backgroundColor: getLanguageColor(language),
color: getLanguageColor(language),
}}
className={"absolute right-2 top-2 opacity-0 duration-200 group-hover:opacity-100"}
/>

View File

@ -1,40 +1,69 @@
import { jotaiStore } from "@follow/utils/jotai"
import { cn } from "@follow/utils/utils"
import type { Variants } from "framer-motion"
import { AnimatePresence, m } from "framer-motion"
import { atom, useAtom, useStore } from "jotai"
import * as React from "react"
import { useEffect } from "react"
import {
CollapseGroupItemStateProvider,
CollapseStateProvider,
useCurrentCollapseId,
useSetCollapseGroupItemState,
useSetCurrentCollapseId,
} from "./hooks"
import type { CollapseContextValue } from "./hooks"
import { CollaspeContext, useCollapseContext } from "./hooks"
interface CollapseProps {
title: React.ReactNode
hideArrow?: boolean
defaultOpen?: boolean
collapseId?: string
onOpenChange?: (isOpened: boolean) => void
}
export const CollapseGroup: Component = ({ children }) => (
<CollapseStateProvider>
<CollapseGroupItemStateProvider>{children}</CollapseGroupItemStateProvider>
</CollapseStateProvider>
)
export const Collapse: Component<CollapseProps> = (props) => {
const [isOpened, setIsOpened] = React.useState(false)
const id = React.useId()
const setCurrentId = useSetCurrentCollapseId()
const currentId = useCurrentCollapseId()
const setItemStatus = useSetCollapseGroupItemState()
export const CollapseGroup: Component<{
defaultOpenId?: string
onOpenChange?: (state: Record<string, boolean>) => void
}> = ({ children, defaultOpenId, onOpenChange }) => {
const ctxValue = React.useMemo<CollapseContextValue>(
() => ({
currentOpenCollapseIdAtom: atom<string | null>(defaultOpenId ?? null),
collapseGroupItemStateAtom: atom<Record<string, boolean>>({}),
}),
[defaultOpenId],
)
const store = useStore()
useEffect(() => {
return store.sub(ctxValue.collapseGroupItemStateAtom, () => {
const state = store.get(ctxValue.collapseGroupItemStateAtom)
onOpenChange?.(state)
})
}, [defaultOpenId])
return <CollaspeContext.Provider value={ctxValue}>{children}</CollaspeContext.Provider>
}
export const Collapse: Component<CollapseProps> = ({ onOpenChange, ...props }) => {
const [isOpened, setIsOpened] = React.useState(props.defaultOpen ?? false)
const reactId = React.useId()
const id = props.collapseId ?? reactId
const { currentOpenCollapseIdAtom, collapseGroupItemStateAtom } = useCollapseContext()
const [currentId, setCurrentId] = useAtom(currentOpenCollapseIdAtom)
React.useEffect(() => {
if (isOpened) {
setCurrentId(id)
}
setItemStatus((prev) => ({ ...prev, [id]: isOpened }))
}, [id, isOpened, setCurrentId, setItemStatus])
const prevState = jotaiStore.get(collapseGroupItemStateAtom)
jotaiStore.set(collapseGroupItemStateAtom, { ...prevState, [id]: isOpened })
return () => {
const prevState = jotaiStore.get(collapseGroupItemStateAtom)
delete prevState[id]
jotaiStore.set(collapseGroupItemStateAtom, prevState)
}
}, [collapseGroupItemStateAtom, id, isOpened, setCurrentId])
React.useEffect(() => {
setIsOpened(currentId === id)
const isOpened = currentId === id
setIsOpened(isOpened)
onOpenChange?.(isOpened)
}, [currentId, id])
return <CollapseControlled isOpened={isOpened} onOpenChange={setIsOpened} {...props} />
}

View File

@ -1,18 +1,15 @@
import { createContextState } from "foxact/context-state"
import { useContext } from "react"
import type { PrimitiveAtom } from "jotai"
import { createContext, useContext } from "react"
export const [
CollapseStateProvider,
useCurrentCollapseId,
useSetCurrentCollapseId,
____CollapseStateContext,
] = createContextState<string | null>(null)
export const [
CollapseGroupItemStateProvider,
useCollapseGroupItemState,
useSetCollapseGroupItemState,
____CollapseGroupItemStateContext,
] = createContextState<Record<string, boolean>>({})
export const useCollapseId = () => useContext(____CollapseStateContext)
export const useCollapseGroupItem = () => useContext(____CollapseGroupItemStateContext)
export interface CollapseContextValue {
currentOpenCollapseIdAtom: PrimitiveAtom<string | null>
collapseGroupItemStateAtom: PrimitiveAtom<Record<string, boolean>>
}
export const CollaspeContext = createContext<CollapseContextValue>(null!)
export const useCollapseContext = () => {
const ctx = useContext(CollaspeContext)
if (!ctx) {
throw new Error("CollapseContext not found")
}
return ctx
}

View File

@ -1,3 +1,4 @@
import { MagneticHoverEffect } from "@follow/components/ui/effect/MagneticHoverEffect.js"
import {
Tooltip,
TooltipContent,
@ -31,9 +32,10 @@ export const MarkdownLink = (props: LinkProps) => {
return (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<a
<MagneticHoverEffect
as="a"
draggable="false"
className="follow-link--underline text-foreground font-semibold no-underline"
className="text-foreground before:!bg-accent/50 font-semibold no-underline"
href={populatedFullHref}
title={props.title}
target="_blank"
@ -44,7 +46,7 @@ export const MarkdownLink = (props: LinkProps) => {
{typeof props.children === "string" && (
<i className="i-mgc-arrow-right-up-cute-re size-[0.9em] translate-y-[2px] opacity-70" />
)}
</a>
</MagneticHoverEffect>
</TooltipTrigger>
{!!props.href && (
<TooltipPortal>

View File

@ -0,0 +1,32 @@
import { Tooltip, TooltipContent, TooltipTrigger } from "@follow/components/ui/tooltip/index.js"
import { cn } from "@follow/utils/utils"
import type { ReactNode } from "react"
export const PeekModalBaseButton = ({
onClick,
className,
label,
icon,
}: {
onClick: () => void
className?: string
label: string
icon: ReactNode
}) => {
return (
<Tooltip>
<TooltipTrigger
type="button"
className={cn(
"no-drag-region center bg-background flex size-8 rounded-full p-1 shadow-sm ring-1 ring-zinc-200 dark:ring-neutral-800",
className,
)}
onClick={onClick}
>
{icon}
<span className="sr-only">{label}</span>
</TooltipTrigger>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
)
}

View File

@ -1,26 +1,18 @@
import { Tooltip, TooltipContent, TooltipTrigger } from "@follow/components/ui/tooltip/index.js"
import { cn } from "@follow/utils/utils"
import { useTranslation } from "react-i18next"
import { PeekModalBaseButton } from "./base"
export const FixedModalCloseButton: Component<{
onClick: () => void
className?: string
}> = ({ onClick, className }) => {
const { t } = useTranslation("common")
return (
<Tooltip>
<TooltipTrigger
type="button"
className={cn(
"no-drag-region center bg-background flex size-8 rounded-full p-1 shadow-sm ring-1 ring-zinc-200 dark:ring-neutral-800",
className,
)}
onClick={onClick}
>
<i className="i-mgc-close-cute-re text-lg" />
<span className="sr-only">{t("words.close")}</span>
</TooltipTrigger>
<TooltipContent>{t("words.close")}</TooltipContent>
</Tooltip>
<PeekModalBaseButton
onClick={onClick}
className={className}
label={t("words.close")}
icon={<i className="i-mgc-close-cute-re text-lg" />}
/>
)
}

View File

@ -1,11 +1,12 @@
import { Tooltip, TooltipContent, TooltipTrigger } from "@follow/components/ui/tooltip/index.js"
import type { PropsWithChildren } from "react"
import { createContext, useContext } from "react"
import { getStableRouterNavigate } from "@follow/components/atoms/route.js"
import { RootPortalProvider } from "@follow/components/ui/portal/provider.js"
import type { PropsWithChildren, ReactNode } from "react"
import { createContext, useContext, useState } from "react"
import { useTranslation } from "react-i18next"
import { Link } from "react-router"
import { m } from "~/components/common/Motion"
import { PeekModalBaseButton } from "../components/base"
import { FixedModalCloseButton } from "../components/close"
import { useCurrentModal, useModalStack } from "../stacked/hooks"
@ -14,46 +15,62 @@ export const useInPeekModal = () => useContext(InPeekModal)
export const PeekModal = (
props: PropsWithChildren<{
to?: string
rightActions?: {
onClick: () => void
label: string
icon: ReactNode
}[]
}>,
) => {
const { dismissAll } = useModalStack()
const { to, children } = props
const { t } = useTranslation("common")
const { dismiss } = useCurrentModal()
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null)
return (
<div className="scrollbar-none relative mx-auto mt-[10vh] max-w-full overflow-auto px-2 lg:max-w-[65rem] lg:p-0">
<m.div
exit={{ opacity: 0, y: 50 }}
transition={{ duration: 0.2 }}
className="motion-preset-slide-up motion-duration-200 motion-ease-spring-smooth scrollbar-none overflow-hidden"
<RootPortalProvider value={rootRef as HTMLElement}>
<div
className="scrollbar-none relative mx-auto mt-[10vh] max-w-full overflow-auto px-2 lg:max-w-[65rem] lg:p-0"
ref={setRootRef}
>
<InPeekModal.Provider value={true}>{children}</InPeekModal.Provider>
</m.div>
<m.div
initial={true}
exit={{
opacity: 0,
}}
className="safe-inset-top-4 fixed right-4 flex items-center gap-4"
>
{!!to && (
<Tooltip>
<TooltipTrigger asChild>
<Link
className="center cursor-button bg-theme-background flex size-8 rounded-full p-1 shadow-sm ring-1 ring-zinc-200 dark:ring-neutral-800"
to={to}
onClick={dismissAll}
>
<i className="i-mgc-fullscreen-2-cute-re text-lg" />
<span className="sr-only">Go to this link</span>
</Link>
</TooltipTrigger>
<TooltipContent>{t("words.expand")}</TooltipContent>
</Tooltip>
)}
<FixedModalCloseButton onClick={dismiss} />
</m.div>
</div>
<m.div
exit={{ opacity: 0, y: 50 }}
transition={{ duration: 0.2 }}
className="motion-preset-slide-up motion-duration-200 motion-ease-spring-smooth scrollbar-none overflow-hidden"
>
<InPeekModal.Provider value={true}>{children}</InPeekModal.Provider>
</m.div>
<m.div
initial={true}
exit={{
opacity: 0,
}}
className="safe-inset-top-4 fixed right-4 flex items-center gap-4"
>
{props.rightActions?.map((action) => (
<PeekModalBaseButton
key={action.label}
onClick={action.onClick}
label={action.label}
icon={action.icon}
/>
))}
{!!to && (
<PeekModalBaseButton
onClick={() => {
dismissAll()
getStableRouterNavigate()?.(to)
}}
label={t("words.expand")}
icon={<i className="i-mgc-fullscreen-2-cute-re text-lg" />}
/>
)}
<FixedModalCloseButton onClick={dismiss} />
</m.div>
</div>
</RootPortalProvider>
)
}

View File

@ -96,7 +96,6 @@ export const ModalInternal = memo(
if (!CustomModalComponent) {
dismissing().then(() => {
setStack((p) => p.filter((modal) => modal.id !== item.id))
setCurrentIsClosing(false)
})
} else {
nextFrame(() => {

View File

@ -72,6 +72,7 @@ export type EntryActionItem = {
export const useEntryActions = ({ entryId, view }: { entryId: string; view?: FeedViewType }) => {
const entry = useEntry(entryId)
const imageLength = entry?.entries.media?.filter((a) => a.type === "photo").length || 0
const feed = useFeedById(entry?.feedId, (feed) => {
return {
type: feed.type,
@ -122,6 +123,10 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view?: Fee
id: COMMAND_ID.integration.saveToReadeck,
onClick: runCmdFn(COMMAND_ID.integration.saveToReadeck, [{ entryId }]),
},
{
id: COMMAND_ID.integration.saveToCubox,
onClick: runCmdFn(COMMAND_ID.integration.saveToCubox, [{ entryId }]),
},
{
id: COMMAND_ID.entry.tip,
onClick: runCmdFn(COMMAND_ID.entry.tip, [
@ -152,8 +157,14 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view?: Fee
id: COMMAND_ID.entry.exportAsPDF,
onClick: runCmdFn(COMMAND_ID.entry.exportAsPDF, [{ entryId }]),
},
{
id: COMMAND_ID.entry.imageGallery,
hide: imageLength <= 5,
onClick: runCmdFn(COMMAND_ID.entry.imageGallery, [{ entryId }]),
},
{
id: COMMAND_ID.entry.openInBrowser,
hide: !entry?.entries.url,
onClick: runCmdFn(COMMAND_ID.entry.openInBrowser, [{ entryId }]),
},
{
@ -211,6 +222,7 @@ export const useEntryActions = ({ entryId, view }: { entryId: string; view?: Fee
feed?.id,
feed?.ownerUserId,
hasEntry,
imageLength,
inList,
isInbox,
isShowAISummaryAuto,

View File

@ -1,6 +1,6 @@
import { parseHtml } from "@follow/components/ui/markdown/parse-html.js"
import { views } from "@follow/constants"
import type { SupportedLanguages } from "@follow/models/types"
import type { SupportedActionLanguage } from "@follow/shared"
import { ACTION_LANGUAGE_MAP } from "@follow/shared"
import { franc } from "franc-min"
@ -17,7 +17,7 @@ export const checkLanguage = ({
language,
}: {
content: string
language: SupportedLanguages
language: SupportedActionLanguage
}) => {
if (!content) return true
const pureContent = parseHtml(content)
@ -44,7 +44,7 @@ export async function translate({
}: {
entry?: FlatEntryModel | null
view?: number
language?: SupportedLanguages
language?: SupportedActionLanguage
extraFields?: string[]
part?: string
}) {

View File

@ -1,13 +1,14 @@
import { cn } from "@follow/utils/utils"
import { useEffect } from "react"
import { useSetAtom } from "jotai"
import type { FC } from "react"
import { useContext } from "react"
import { CollapseGroup } from "~/components/ui/collapse"
import { useCollapseGroupItemState } from "~/components/ui/collapse/hooks"
import { setEntryContentPlaceholderLogoShow } from "~/modules/entry-content/atoms"
import { EntryContentPlaceholderContext } from "~/modules/app-layout/entry-content/EntryContentPlaceholderContext"
import { DayOf } from "./constants"
import { DailyItem } from "./daily"
import type { DailyView } from "./types"
import { DailyReportTitle } from "./daily"
import type { DailyItemProps, DailyView } from "./types"
import { useParseDailyDate } from "./useParseDailyDate"
export const EntryPlaceholderDaily = ({
view,
@ -17,18 +18,23 @@ export const EntryPlaceholderDaily = ({
className?: string
}) => (
<div className={cn(className, "mx-auto flex w-full max-w-[75ch] flex-col gap-6")}>
<CollapseGroup>
<CtxConsumer />
<DailyItem day={DayOf.Today} view={view} />
<DailyItem day={DayOf.Yesterday} view={view} />
</CollapseGroup>
<NormalDailyReportTitle day={DayOf.Today} view={view} />
<NormalDailyReportTitle day={DayOf.Yesterday} view={view} />
</div>
)
const CtxConsumer = () => {
const status = useCollapseGroupItemState()
const isAllCollapsed = Object.values(status).every((v) => !v)
useEffect(() => {
setEntryContentPlaceholderLogoShow(isAllCollapsed)
}, [isAllCollapsed])
return null
const NormalDailyReportTitle: FC<DailyItemProps> = ({ day }) => {
const { title, startDate, endDate } = useParseDailyDate(day)
const setOpenedSummary = useSetAtom(useContext(EntryContentPlaceholderContext).openedSummary)
return (
<button
type="button"
onClick={() => setOpenedSummary(day)}
className="border-b pb-6 last:border-b-0"
>
<DailyReportTitle title={title} startDate={startDate} endDate={endDate} />
</button>
)
}

View File

@ -1,4 +1,4 @@
export enum DayOf {
Today,
Today = 1,
Yesterday,
}

View File

@ -2,6 +2,7 @@ import { EmptyIcon } from "@follow/components/icons/empty.jsx"
import { AutoResizeHeight } from "@follow/components/ui/auto-resize-height/index.jsx"
import { Card, CardContent } from "@follow/components/ui/card/index.jsx"
import { LoadingCircle } from "@follow/components/ui/loading/index.jsx"
import { RootPortal } from "@follow/components/ui/portal/index.js"
import { ScrollArea } from "@follow/components/ui/scroll-area/index.js"
import {
Tooltip,
@ -14,11 +15,18 @@ import { cn, isBizId } from "@follow/utils/utils"
import type { Variant } from "framer-motion"
import { m, useAnimationControls } from "framer-motion"
import type { Components } from "hast-util-to-jsx-runtime"
import { useEffect, useState } from "react"
import type { FC } from "react"
import { useEffect, useMemo, useState } from "react"
import { Trans, useTranslation } from "react-i18next"
import { useGeneralSettingSelector } from "~/atoms/settings/general"
import { Collapse } from "~/components/ui/collapse"
import { RelativeTime } from "~/components/ui/datetime"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu/dropdown-menu"
import type { LinkProps } from "~/components/ui/link"
import { Markdown } from "~/components/ui/markdown/Markdown"
import { MarkdownLink } from "~/components/ui/markdown/renderers"
@ -28,12 +36,17 @@ import { PeekModal } from "~/components/ui/modal/inspire/PeekModal"
import { PlainModal } from "~/components/ui/modal/stacked/custom-modal"
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { Paper } from "~/components/ui/paper"
import { useSortedEntryActions } from "~/hooks/biz/useEntryActions"
import { getRouteParams } from "~/hooks/biz/useRouteParams"
import { useAuthQuery } from "~/hooks/common"
import { apiClient } from "~/lib/api-fetch"
import { defineQuery } from "~/lib/defineQuery"
import { COMMAND_ID } from "~/modules/command/commands/id"
import { hasCommand } from "~/modules/command/hooks/use-command"
import { FlatMarkAllReadButton } from "~/modules/entry-column/components/mark-all-button"
import { StarIcon } from "~/modules/entry-column/star-icon"
import { EntryContent } from "~/modules/entry-content"
import { CommandDropdownMenuItem } from "~/modules/entry-content/actions/more-actions"
import { FeedIcon } from "~/modules/feed/feed-icon"
import { Queries } from "~/queries"
import { useEntry } from "~/store/entry"
@ -42,13 +55,15 @@ import { useFeedById } from "~/store/feed"
import type { DailyItemProps, DailyView } from "./types"
import { useParseDailyDate } from "./useParseDailyDate"
export const DailyItem = ({ view, day }: DailyItemProps) => {
export const DailyItem = ({ view, day, className }: DailyItemProps) => {
const { title, startDate, endDate } = useParseDailyDate(day)
return (
<Collapse
collapseId={`${day}`}
hideArrow
title={<DailyReportTitle title={title} startDate={startDate} endDate={endDate} />}
className="mx-auto w-full max-w-lg border-b pb-6 last:border-b-0"
className={cn(className, "mx-auto w-full max-w-lg border-b pb-6 last:border-b-0")}
>
<DailyReportContent endDate={endDate} view={view} startDate={startDate} />
</Collapse>
@ -65,8 +80,20 @@ export const DailyReportTitle = ({
endDate: number
}) => {
const { t } = useTranslation()
const language = useGeneralSettingSelector((s) => s.language)
const locale = useMemo(() => {
try {
return new Intl.Locale(language)
} catch {
return new Intl.Locale("en-US")
}
}, [language])
return (
<div className="flex items-center justify-center gap-2 text-base">
<m.div
className="flex items-center justify-center gap-2 text-base"
layoutId={`daily-report-title-${title}`}
>
<i className="i-mgc-magic-2-cute-re" />
<div className="font-medium">{t("ai_daily.title", { title })}</div>
<Tooltip>
@ -82,7 +109,7 @@ export const DailyReportTitle = ({
components={{
From: (
<span>
{new Date(startDate).toLocaleTimeString("en-US", {
{new Date(startDate).toLocaleTimeString(locale, {
weekday: "short",
hour: "numeric",
minute: "numeric",
@ -91,7 +118,7 @@ export const DailyReportTitle = ({
),
To: (
<span>
{new Date(endDate + 1).toLocaleTimeString("en-US", {
{new Date(endDate + 1).toLocaleTimeString(locale, {
weekday: "short",
hour: "numeric",
minute: "numeric",
@ -106,7 +133,7 @@ export const DailyReportTitle = ({
</TooltipContent>
</TooltipPortal>
</Tooltip>
</div>
</m.div>
)
}
@ -151,7 +178,7 @@ export const DailyReportContent: Component<DailyReportContentProps> = ({
return (
<Card className="border-none bg-transparent">
<CardContent className={cn("space-y-0 p-0", className)}>
<ScrollArea.ScrollArea mask={false} flex viewportClassName="max-h-[calc(100vh-500px)]">
<ScrollArea.ScrollArea mask={false} flex viewportClassName="max-h-[calc(100vh-176px)]">
<AutoResizeHeight spring>
{content.isLoading ? (
<LoadingCircle size="large" className="mt-8 text-center" />
@ -270,7 +297,21 @@ const createRelatedEntryLink = (variant: "toast" | "modal") => (props: LinkProps
// eslint-disable-next-line @eslint-react/no-nested-component-definitions
CustomModalComponent: ({ children }) => {
const { feedId } = useEntry(entryId) || {}
return <PeekModal to={`/timeline/${feedId}/${entryId}`}>{children}</PeekModal>
return (
<PeekModal
rightActions={[
{
onClick: () => {},
label: "More Actions",
icon: <EntryMoreActions entryId={entryId} />,
},
]}
to={`/timeline/view-${getRouteParams().view}/${feedId}/${entryId}`}
>
{children}
</PeekModal>
)
},
content: () => <EntryModalPreview entryId={entryId} />,
overlay: true,
@ -411,3 +452,47 @@ const EntryModalPreview = ({ entryId }: { entryId: string }) => (
/>
</Paper>
)
const EntryMoreActions: FC<{ entryId: string }> = ({ entryId }) => {
const { view } = getRouteParams()
const { moreAction, mainAction } = useSortedEntryActions({ entryId, view })
const actionConfigs = useMemo(
() => [...moreAction, ...mainAction].filter((action) => hasCommand(action.id)),
[moreAction, mainAction],
)
const availableActions = useMemo(
() => actionConfigs.filter((item) => item.id !== COMMAND_ID.settings.customizeToolbar),
[actionConfigs],
)
const extraAction = useMemo(
() => actionConfigs.filter((item) => item.id === COMMAND_ID.settings.customizeToolbar),
[actionConfigs],
)
if (availableActions.length === 0 && extraAction.length === 0) {
return null
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<i className="i-mgc-more-1-cute-re" />
</DropdownMenuTrigger>
<RootPortal>
<DropdownMenuContent>
{availableActions.map((config) => (
<CommandDropdownMenuItem
key={config.id}
commandId={config.id}
onClick={config.onClick}
active={config.active}
/>
))}
</DropdownMenuContent>
</RootPortal>
</DropdownMenu>
)
}

View File

@ -6,4 +6,6 @@ export type DailyView = Extract<FeedViewType, FeedViewType.Articles | FeedViewTy
export interface DailyItemProps {
view: DailyView
day: DayOf
className?: string
}

View File

@ -0,0 +1,93 @@
import { FeedViewType } from "@follow/constants"
import { cn } from "@follow/utils/utils"
import { AnimatePresence, LayoutGroup, m } from "framer-motion"
import { atom, useAtomValue, useSetAtom } from "jotai"
import * as React from "react"
import { CollapseGroup } from "~/components/ui/collapse"
import { PeekModalBaseButton } from "~/components/ui/modal/components/base"
import { ROUTE_FEED_PENDING } from "~/constants"
import { useRouteParams } from "~/hooks/biz/useRouteParams"
import { DayOf } from "~/modules/ai/ai-daily/constants"
import { DailyItem } from "~/modules/ai/ai-daily/daily"
import { EntryPlaceholderDaily } from "~/modules/ai/ai-daily/EntryPlaceholderDaily"
import type { DailyView } from "~/modules/ai/ai-daily/types"
import { EntryPlaceholderLogo } from "~/modules/entry-content/components/EntryPlaceholderLogo"
import type { EntryContentPlaceholderContextValue } from "./EntryContentPlaceholderContext"
import { EntryContentPlaceholderContext } from "./EntryContentPlaceholderContext"
export const EntryContentPlaceholder = () => {
const { feedId, view } = useRouteParams()
const ctxValue = React.useMemo<EntryContentPlaceholderContextValue>(
() => ({
openedSummary: atom<null | DayOf>(null),
}),
[],
)
const openedSummary = useAtomValue(ctxValue.openedSummary)
return (
<LayoutGroup>
<EntryContentPlaceholderContext.Provider value={ctxValue}>
<AnimatePresence>
{openedSummary === null ? (
<m.div
className="center size-full flex-col"
initial={{ opacity: 0.01, y: 300 }}
animate={{ opacity: 1, y: 0 }}
>
<EntryPlaceholderLogo />
{feedId === ROUTE_FEED_PENDING && view === FeedViewType.Articles && (
<EntryPlaceholderDaily view={view} />
)}
</m.div>
) : (
<SummaryDetailContent />
)}
</AnimatePresence>
</EntryContentPlaceholderContext.Provider>
</LayoutGroup>
)
}
const SummaryDetailContent = () => {
const { view } = useRouteParams()
const ctxValue = React.useContext(EntryContentPlaceholderContext)
const openedSummary = useAtomValue(ctxValue.openedSummary)
const setOpenedSummary = useSetAtom(ctxValue.openedSummary)
return (
<div className="relative">
<div className="fade-in-0 absolute right-4 top-4 duration-200">
<PeekModalBaseButton
icon={<i className="i-mgc-close-cute-re" />}
label="Back"
onClick={() => setOpenedSummary(null)}
/>
</div>
<CollapseGroup
defaultOpenId={`${openedSummary}`}
onOpenChange={(state) => {
const isAllClosed = Object.values(state).every((v) => !v)
if (isAllClosed) {
setOpenedSummary(null)
}
}}
>
<DailyItem
day={DayOf.Today}
view={view as DailyView}
className={cn(openedSummary === DayOf.Today && "grow", "pt-6")}
/>
<DailyItem
day={DayOf.Yesterday}
view={view as DailyView}
className={cn(openedSummary === DayOf.Yesterday && "grow", "pt-6")}
/>
</CollapseGroup>
</div>
)
}

View File

@ -0,0 +1,12 @@
import type { PrimitiveAtom } from "jotai"
import { createContext } from "react"
import type { DayOf } from "~/modules/ai/ai-daily/constants"
export const EntryContentPlaceholderContext = createContext<EntryContentPlaceholderContextValue>(
null!,
)
export type EntryContentPlaceholderContextValue = {
openedSummary: PrimitiveAtom<null | DayOf>
}

View File

@ -1,4 +1,4 @@
import { FeedViewType, views } from "@follow/constants"
import { views } from "@follow/constants"
import { cn } from "@follow/utils/utils"
import { useWheel } from "@use-gesture/react"
import { easeOut } from "framer-motion"
@ -11,17 +11,17 @@ import { useRealInWideMode } from "~/atoms/settings/ui"
import { useTimelineColumnShow, useTimelineColumnTempShow } from "~/atoms/sidebar"
import { m } from "~/components/common/Motion"
import { FixedModalCloseButton } from "~/components/ui/modal/components/close"
import { HotKeyScopeMap, ROUTE_ENTRY_PENDING, ROUTE_FEED_PENDING } from "~/constants"
import { HotKeyScopeMap, ROUTE_ENTRY_PENDING } from "~/constants"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { useRouteParams } from "~/hooks/biz/useRouteParams"
import { EntryPlaceholderDaily } from "~/modules/ai/ai-daily/EntryPlaceholderDaily"
import { EntryContent } from "~/modules/entry-content"
import { EntryPlaceholderLogo } from "~/modules/entry-content/components/EntryPlaceholderLogo"
import { AppLayoutGridContainerProvider } from "~/providers/app-grid-layout-container-provider"
import { EntryContentPlaceholder } from "./EntryContentPlaceholder"
export const RightContentDesktop = () => {
const { entryId } = useParams()
const { feedId, view } = useRouteParams()
const { view } = useRouteParams()
const navigate = useNavigateEntry()
const settingWideMode = useRealInWideMode()
@ -69,16 +69,7 @@ export const RightContentDesktop = () => {
}}
/>
) : !settingWideMode ? (
<m.div
className="center size-full flex-col"
initial={{ opacity: 0.01, y: 300 }}
animate={{ opacity: 1, y: 0 }}
>
<EntryPlaceholderLogo />
{feedId === ROUTE_FEED_PENDING && view === FeedViewType.Articles && (
<EntryPlaceholderDaily view={view} />
)}
</m.div>
<EntryContentPlaceholder />
) : null}
</EntryGridContainer>
</AppLayoutGridContainerProvider>

View File

@ -17,6 +17,7 @@ import { navigateEntry } from "~/hooks/biz/useNavigateEntry"
import { getRouteParams } from "~/hooks/biz/useRouteParams"
import { tipcClient } from "~/lib/client"
import { useActivationModal } from "~/modules/activation"
import { useGalleryModal } from "~/modules/entry-content/hooks"
import { useTipModal } from "~/modules/wallet/hooks"
import { entryActions, useEntryStore } from "~/store/entry"
@ -92,6 +93,7 @@ export const useRegisterEntryCommands = () => {
const deleteInboxEntry = useDeleteInboxEntry()
const showSourceContentModal = useSourceContentModal()
const openTipModal = useTipModal()
const openGalleryModal = useGalleryModal()
const read = useRead()
const unread = useUnread()
@ -274,7 +276,10 @@ export const useRegisterEntryCommands = () => {
if (IN_ELECTRON) {
return tipcClient?.showShareMenu(entry.entries.url)
} else {
const { title, description } = entry.entries
navigator.share({
title: title || undefined,
text: description || undefined,
url: entry.entries.url,
})
}
@ -300,6 +305,14 @@ export const useRegisterEntryCommands = () => {
}
},
},
{
id: COMMAND_ID.entry.imageGallery,
label: t("entry_actions.image_gallery"),
icon: <i className="i-mgc-pic-cute-fi" />,
run: ({ entryId }) => {
openGalleryModal(entryId)
},
},
])
useRegisterFollowCommand(

View File

@ -12,6 +12,7 @@ export const COMMAND_ID = {
toggleAISummary: "entry:toggle-ai-summary",
toggleAITranslation: "entry:toggle-ai-translation",
exportAsPDF: "entry:export-as-pdf",
imageGallery: "entry:image-gallery",
},
integration: {
saveToEagle: "integration:save-to-eagle",
@ -20,6 +21,7 @@ export const COMMAND_ID = {
saveToObsidian: "integration:save-to-obsidian",
saveToOutline: "integration:save-to-outline",
saveToReadeck: "integration:save-to-readeck",
saveToCubox: "integration:save-to-cubox",
},
list: {
edit: "list:edit",

View File

@ -1,4 +1,5 @@
import {
SimpleIconsCubox,
SimpleIconsEagle,
SimpleIconsInstapaper,
SimpleIconsObsidian,
@ -33,6 +34,7 @@ export const useRegisterIntegrationCommands = () => {
useRegisterObsidianCommands()
useRegisterOutlineCommands()
useRegisterReadeckCommands()
useRegisterCuboxCommands()
}
const useRegisterEagleCommands = () => {
@ -453,3 +455,87 @@ const useRegisterReadeckCommands = () => {
},
)
}
const useRegisterCuboxCommands = () => {
const { t } = useTranslation()
const enableCubox = useIntegrationSettingKey("enableCubox")
const cuboxToken = useIntegrationSettingKey("cuboxToken")
const enableCuboxAutoMemo = useIntegrationSettingKey("enableCuboxAutoMemo")
const cuboxAvailable = enableCubox && !!cuboxToken
const buildUrlRequestBody = (entry: FlatEntryModel) => {
return {
type: "url",
content: entry.entries.url || "",
title: entry.entries.title || "",
description: entry.entries.description || "",
tags: [],
folder: "",
}
}
const buildMemoRequestBody = (entry: FlatEntryModel, selectedText: string) => {
return {
type: "memo",
content: selectedText,
title: entry.entries.title || "",
description: entry.entries.description || "",
tags: [],
folder: "",
source_url: entry.entries.url,
}
}
useRegisterCommandEffect(
!cuboxAvailable
? []
: defineFollowCommand({
id: COMMAND_ID.integration.saveToCubox,
label: t("entry_actions.save_to_cubox"),
icon: <SimpleIconsCubox />,
run: async ({ entryId }) => {
const entry = useEntryStore.getState().flatMapEntries[entryId]
if (!entry) {
toast.error("Failed to save to Cubox: entry is not available", { duration: 3000 })
return
}
try {
tracker.integration({
type: "cubox",
event: "save",
})
const selectedText = window.getSelection()?.toString() || ""
const requestBody =
selectedText && enableCuboxAutoMemo
? buildMemoRequestBody(entry, selectedText)
: buildUrlRequestBody(entry)
await ofetch(cuboxToken, {
method: "POST",
body: requestBody,
headers: {
"Content-Type": "application/json",
},
})
toast.success(t("entry_actions.saved_to_cubox"), {
duration: 3000,
})
} catch (error) {
toast.error(
`${t("entry_actions.failed_to_save_to_cubox")}: ${(error as FetchError)?.message || ""}`,
{
duration: 3000,
},
)
}
},
}),
{
deps: [cuboxAvailable, cuboxToken, enableCuboxAutoMemo],
},
)
}

View File

@ -65,6 +65,11 @@ export type ToggleAITranslationCommand = Command<{
fn: () => void
}>
export type ImageGalleryCommand = Command<{
id: typeof COMMAND_ID.entry.imageGallery
fn: ({ entryId }) => void
}>
export type EntryCommand =
| TipCommand
| StarCommand
@ -78,6 +83,7 @@ export type EntryCommand =
| ReadCommand
| ToggleAISummaryCommand
| ToggleAITranslationCommand
| ImageGalleryCommand
// Settings commands
@ -120,6 +126,11 @@ export type SaveToReadeckCommand = Command<{
fn: (payload: { entryId: string }) => void
}>
export type SaveToCuboxCommand = Command<{
id: typeof COMMAND_ID.integration.saveToCubox
fn: (payload: { entryId: string }) => void
}>
export type IntegrationCommand =
| SaveToEagleCommand
| SaveToReadwiseCommand
@ -127,5 +138,6 @@ export type IntegrationCommand =
| SaveToObsidianCommand
| SaveToOutlineCommand
| SaveToReadeckCommand
| SaveToCuboxCommand
export type BasicCommand = EntryCommand | SettingsCommand | IntegrationCommand

View File

@ -3,8 +3,28 @@ import { useMemo } from "react"
import { useUISettingSelector } from "~/atoms/settings/ui"
import { DEFAULT_ACTION_ORDER } from "./constant"
export const useActionOrder = () => {
const actionOrderSetting = useUISettingSelector((s) => s.toolbarOrder)
return useMemo(() => {
const { main, more } = actionOrderSetting
const missingMainActions = DEFAULT_ACTION_ORDER.main.filter(
(id) => !main.includes(id) && !more.includes(id),
)
const missingMoreActions = DEFAULT_ACTION_ORDER.more.filter(
(id) => !main.includes(id) && !more.includes(id),
)
return {
main: [...actionOrderSetting.main, ...missingMainActions],
more: [...actionOrderSetting.more, ...missingMoreActions],
}
}, [actionOrderSetting])
}
export const useToolbarOrderMap = () => {
const actionOrder = useUISettingSelector((s) => s.toolbarOrder)
const actionOrder = useActionOrder()
const actionOrderMap = useMemo(() => {
const actionOrderMap = new Map<

View File

@ -17,15 +17,16 @@ import { Button } from "@follow/components/ui/button/index.js"
import { useCallback } from "react"
import { useTranslation } from "react-i18next"
import { setUISetting, useUISettingSelector } from "~/atoms/settings/ui"
import { setUISetting } from "~/atoms/settings/ui"
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { DEFAULT_ACTION_ORDER } from "./constant"
import { DroppableContainer, SortableActionButton } from "./dnd"
import { useActionOrder } from "./hooks"
const CustomizeToolbar = () => {
const { t } = useTranslation("settings")
const actionOrder = useUISettingSelector((s) => s.toolbarOrder)
const actionOrder = useActionOrder()
const sensors = useSensors(
useSensor(PointerSensor),

View File

@ -1,6 +1,7 @@
import { useMobile } from "@follow/components/hooks/useMobile.js"
import { Button } from "@follow/components/ui/button/index.js"
import { Card, CardContent, CardFooter, CardHeader } from "@follow/components/ui/card/index.jsx"
import { RelativeTime } from "@follow/components/ui/datetime/index.js"
import {
Form,
FormControl,
@ -10,12 +11,12 @@ import {
FormMessage,
} from "@follow/components/ui/form/index.jsx"
import { Input } from "@follow/components/ui/input/index.js"
import { Radio } from "@follow/components/ui/radio-group/index.js"
import { RadioGroup } from "@follow/components/ui/radio-group/RadioGroup.jsx"
import { ResponsiveSelect } from "@follow/components/ui/select/responsive.js"
import { getBackgroundGradient } from "@follow/utils/color"
import { zodResolver } from "@hookform/resolvers/zod"
import { repository } from "@pkg"
import { useMutation } from "@tanstack/react-query"
import { m } from "framer-motion"
import { produce } from "immer"
import { atom, useAtomValue, useStore } from "jotai"
import type { ChangeEvent, FC } from "react"
@ -24,11 +25,13 @@ import { useForm } from "react-hook-form"
import { useTranslation } from "react-i18next"
import { z } from "zod"
import { smoothPreset } from "~/components/ui/constants/spring"
import { Media } from "~/components/ui/media"
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { useFollow } from "~/hooks/biz/useFollow"
import { getRouteParams } from "~/hooks/biz/useRouteParams"
import { apiClient } from "~/lib/api-fetch"
import { UrlBuilder } from "~/lib/url-builder"
import { FollowSummary } from "../feed/feed-summary"
import { FeedForm } from "./feed-form"
@ -38,6 +41,8 @@ const formSchema = z.object({
target: z.enum(["feeds", "lists"]),
})
const numberFormatter = new Intl.NumberFormat("en-US", {})
const info: Record<
string,
{
@ -45,6 +50,7 @@ const info: Record<
prefix?: string[]
showModal?: boolean
default?: string
labelSuffix?: React.ReactNode
}
> = {
search: {
@ -55,12 +61,34 @@ const info: Record<
default: "https://",
prefix: ["https://", "http://"],
showModal: true,
labelSuffix: (
<a
href={`${repository.url}/wiki/Folo-Flavored-Feed-Spec`}
target="_blank"
rel="noreferrer"
className="text-accent border-accent inline-flex w-auto items-center gap-1 rounded-full border px-2 py-px text-sm font-normal"
>
<i className="i-mgc-book-6-cute-re" />
<span>Folo Flavored Feed Spec</span>
</a>
),
},
rsshub: {
label: "discover.rss_hub_route",
prefix: ["rsshub://"],
default: "rsshub://",
showModal: true,
labelSuffix: (
<a
href="https://docs.rsshub.app/"
target="_blank"
rel="noreferrer"
className="text-accent border-accent inline-flex w-auto items-center gap-1 rounded-full border px-2 py-px text-sm font-normal"
>
<i className="i-mgc-book-6-cute-re" />
<span>RSSHub Docs</span>
</a>
),
},
}
@ -206,7 +234,10 @@ export function DiscoverForm({ type = "search" }: { type?: string }) {
name="keyword"
render={({ field }) => (
<FormItem>
<FormLabel>{t(info[type]?.label!)}</FormLabel>
<FormLabel className="flex items-center gap-4">
{t(info[type]?.label!)}
{info[type]?.labelSuffix}
</FormLabel>
<FormControl>
<Input autoFocus {...field} onChange={handleKeywordChange} />
</FormControl>
@ -234,14 +265,39 @@ export function DiscoverForm({ type = "search" }: { type?: string }) {
]}
/>
) : (
<RadioGroup
className="flex items-center"
value={field.value}
onValueChange={handleTargetChange}
>
<Radio label={t("discover.target.feeds")} value="feeds" />
<Radio label={t("discover.target.lists")} value="lists" />
</RadioGroup>
<div className="relative flex h-7 items-stretch overflow-hidden rounded-lg bg-zinc-100/80 p-0.5 shadow-inner dark:bg-zinc-800/80">
<m.div
className="absolute left-0.5 top-0.5 z-0 h-6 rounded-md bg-white shadow-sm dark:bg-zinc-700"
initial={false}
animate={{
x: field.value === "lists" ? "calc(100% + 2px)" : 0,
width: "calc(50% - 4px)",
}}
transition={smoothPreset}
/>
<button
type="button"
onClick={() => handleTargetChange("feeds")}
className={`relative z-10 min-w-[80px] rounded-md px-4 text-sm font-medium transition-colors duration-200 ${
field.value === "feeds"
? "text-zinc-900 dark:text-white"
: "text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white"
}`}
>
{t("discover.target.feeds")}
</button>
<button
type="button"
onClick={() => handleTargetChange("lists")}
className={`relative z-10 min-w-[80px] rounded-md px-4 text-sm font-medium transition-colors duration-200 ${
field.value === "lists"
? "text-zinc-900 dark:text-white"
: "text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-white"
}`}
>
{t("discover.target.lists")}
</button>
</div>
)}
</div>
</FormControl>
@ -259,7 +315,7 @@ export function DiscoverForm({ type = "search" }: { type?: string }) {
</Form>
{mutation.isSuccess && (
<div className="mt-8 w-full max-w-lg">
<div className="mb-4 text-zinc-500">
<div className="mb-4 pl-7 text-sm text-zinc-500">
{t("discover.search.results", { count: mutation.data?.length || 0 })}
</div>
<div className="space-y-6 text-sm">
@ -287,21 +343,26 @@ const SearchCard: FC<{
const { t } = useTranslation()
return (
<Card data-feed-id={item.feed?.id || item.list?.id} className="select-text">
<CardHeader>
<Card
data-feed-id={item.feed?.id || item.list?.id}
className="select-text border border-zinc-200/50 bg-white/80 shadow-sm backdrop-blur-xl transition-all duration-300 hover:shadow-lg dark:border-zinc-800/50 dark:bg-neutral-800/50"
>
<CardHeader className="pb-2">
<FollowSummary className="max-w-[462px]" feed={item.feed || item.list!} docs={item.docs} />
</CardHeader>
{item.docs ? (
<CardFooter>
<CardFooter className="pt-4">
<a href={item.docs} target="_blank" rel="noreferrer">
<Button>View Docs</Button>
<Button className="rounded-full bg-zinc-900 px-6 text-white transition-opacity hover:opacity-90 dark:bg-white dark:text-zinc-900">
View Docs
</Button>
</a>
</CardFooter>
) : (
<>
<CardContent>
<CardContent className="px-6 py-0">
{!!item.entries?.length && (
<div className="grid grid-cols-4 gap-4">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
{item.entries
.filter((e) => !!e)
.map((entry) => {
@ -311,12 +372,31 @@ const SearchCard: FC<{
key={assertEntry.id}
href={assertEntry.url || void 0}
target="_blank"
className="flex min-w-0 flex-1 flex-col items-center gap-1"
className="group relative flex flex-col overflow-hidden rounded-lg bg-zinc-50/50 shadow-zinc-100 transition-all duration-200 hover:-translate-y-px hover:shadow-md dark:bg-zinc-800/50 dark:shadow-neutral-700/50"
rel="noreferrer"
>
<FeedCardMediaThumbnail entry={assertEntry} />
<div className="line-clamp-2 w-full text-xs leading-tight">
{assertEntry.title}
<div className="aspect-[3/2] w-full overflow-hidden">
<FeedCardMediaThumbnail entry={assertEntry} />
</div>
<div className="flex flex-1 flex-col justify-between p-3">
{assertEntry.title ? (
<div className="line-clamp-2 text-xs font-medium leading-4 text-zinc-900 group-hover:text-black dark:text-zinc-200 dark:group-hover:text-white">
{assertEntry.title}
</div>
) : (
<div className="flex items-center gap-1 text-xs text-zinc-500 dark:text-zinc-400">
<i className="i-mgc-link-cute-re shrink-0 translate-y-px self-start text-[14px]" />
<span className="line-clamp-2 break-all">
{assertEntry.url || "Untitled"}
</span>
</div>
)}
<div className="mt-1 text-xs text-zinc-500 dark:text-zinc-400">
<RelativeTime
date={assertEntry.publishedAt}
displayAbsoluteTimeAfterDay={Infinity}
/>
</div>
</div>
</a>
)
@ -324,31 +404,68 @@ const SearchCard: FC<{
</div>
)}
</CardContent>
<CardFooter>
<Button
variant={item.isSubscribed ? "outline" : undefined}
disabled={item.isSubscribed}
onClick={() => {
follow({
isList: !!item.list?.id,
id: item.list?.id,
url: item.feed?.url,
defaultValues: {
view: getRouteParams().view.toString(),
},
onSuccess() {
onSuccess(item)
},
})
}}
>
{item.isSubscribed ? t("feed.actions.followed") : t("feed.actions.follow")}
</Button>
<div className="ml-6 text-zinc-500">
<span className="font-medium text-zinc-800 dark:text-zinc-200">
{item.subscriptionCount ?? 0}
</span>{" "}
{t("feed.follower", { count: item.subscriptionCount })}
<CardFooter className="mt-4 flex justify-between gap-4 border-t border-zinc-100/80 py-3 dark:border-zinc-800/80">
<div className="flex items-center gap-3 text-sm text-zinc-500 dark:text-zinc-400">
<div className="flex items-center gap-1.5">
<i className="i-mgc-user-3-cute-re" />
<span>
{numberFormatter.format(item.subscriptionCount ?? 0)}{" "}
{t("feed.follower", { count: item.subscriptionCount ?? 0 })}
</span>
</div>
{item.updatesPerWeek ? (
<div className="flex items-center gap-1.5">
<i className="i-mgc-safety-certificate-cute-re" />
<span>{t("feed.entry_week", { count: item.updatesPerWeek ?? 0 })}</span>
</div>
) : item.entries?.[0]?.publishedAt ? (
<div className="flex items-center gap-1.5">
<i className="i-mgc-safe-alert-cute-re" />
<span>{t("feed.updated_at")}</span>
<RelativeTime
date={item.entries[0].publishedAt}
displayAbsoluteTimeAfterDay={Infinity}
/>
</div>
) : null}
</div>
<div className="flex items-center justify-between gap-2">
<Button
variant="ghost"
buttonClassName="rounded-lg px-3 text-sm font-medium text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-zinc-800/80 dark:hover:text-white"
onClick={() => {
if (!item.feed?.id) return
window.open(UrlBuilder.shareFeed(item.feed.id, 0), "_blank")
}}
>
{t("discover.preview")}
</Button>
<Button
variant={item.isSubscribed ? "outline" : undefined}
disabled={item.isSubscribed}
onClick={() => {
follow({
isList: !!item.list?.id,
id: item.list?.id,
url: item.feed?.url,
defaultValues: {
view: getRouteParams().view.toString(),
},
onSuccess() {
onSuccess(item)
},
})
}}
buttonClassName={`relative overflow-hidden rounded-lg text-sm font-medium transition-all duration-300 ${
item.isSubscribed
? "border-zinc-200/80 text-zinc-400 dark:border-zinc-700/80"
: ""
}`}
>
{item.isSubscribed ? t("feed.actions.followed") : t("feed.actions.follow")}
</Button>
</div>
</CardFooter>
</>
@ -360,22 +477,42 @@ const SearchCard: FC<{
const FeedCardMediaThumbnail: FC<{
entry: NonUndefined<DiscoverSearchData[number]["entries"]>[number]
}> = ({ entry }) => {
const [, , , bgAccent, bgAccentLight, bgAccentUltraLight] = getBackgroundGradient(entry.title)
return entry.media?.[0] ? (
<Media
src={entry.media?.[0].url}
type={entry.media?.[0].type}
previewImageUrl={entry.media?.[0].preview_image_url}
className="aspect-square w-full"
/>
) : (
<div
className="relative flex aspect-square w-full items-end overflow-hidden rounded p-2 text-xs leading-tight text-zinc-900"
style={{
background: `linear-gradient(37deg, ${bgAccent} 27.82%, ${bgAccentLight} 79.68%, ${bgAccentUltraLight} 100%)`,
}}
>
<div className="line-clamp-2 text-right font-medium">{entry.title}</div>
const [, , , bgAccent, bgAccentLight] = getBackgroundGradient(
entry.title || entry.url || "Untitled",
)
if (entry.media?.[0]) {
return (
<div className="relative size-full bg-zinc-100 dark:bg-zinc-800">
<Media
src={entry.media[0].url}
type={entry.media[0].type}
previewImageUrl={entry.media[0].preview_image_url}
className="size-full object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/5 to-transparent opacity-0 transition-opacity duration-200 group-hover:opacity-100" />
</div>
)
}
return (
<div className="relative size-full bg-zinc-100 dark:bg-zinc-800">
<div
className="absolute inset-0 transition-transform duration-200 group-hover:scale-[1.01]"
style={{
background: `linear-gradient(145deg, ${bgAccent}, ${bgAccentLight})`,
}}
/>
<div className="absolute inset-0 flex items-center justify-center">
{entry.title ? (
<div className="text-base font-medium text-white/90">
{entry.title.slice(0, 1).toUpperCase()}
</div>
) : (
<i className="i-mingcute-news-line text-xl text-white/80" />
)}
</div>
<div className="absolute inset-0 bg-black/0 transition-colors duration-200 group-hover:bg-black/5" />
</div>
)
}

View File

@ -1,5 +1,6 @@
import { Button } from "@follow/components/ui/button/index.js"
import { UserRole } from "@follow/constants"
import { repository } from "@pkg"
import { useTranslation } from "react-i18next"
import { useEventCallback } from "usehooks-ts"
@ -49,12 +50,13 @@ export function DiscoverInboxList() {
<div className="mb-4 flex flex-wrap items-center gap-2 text-sm text-zinc-500">
<span>{t("discover.inbox.description")}</span>
<a
href="https://github.com/RSSNext/Follow/wiki/Inbox#webhooks"
className="text-zinc-600 underline"
href={`${repository.url}/wiki/Inbox#webhooks`}
target="_blank"
rel="noopener noreferrer"
rel="noreferrer"
className="text-accent border-accent inline-flex w-auto items-center gap-1 rounded-full border px-2 py-px text-sm"
>
{t("discover.inbox.webhooks_docs")}
<i className="i-mgc-book-6-cute-re" />
<span>{t("discover.inbox.webhooks_docs")}</span>
</a>
</div>
<InboxTable />

View File

@ -1,44 +0,0 @@
import { LoadingCircle } from "@follow/components/ui/loading/index.jsx"
import { TokenBrandedRss3 } from "@follow/components/ui/platform-icon/icons.js"
import { useAuthQuery } from "~/hooks/common"
import { Queries } from "~/queries"
import { DiscoverFeedForm } from "./DiscoverFeedForm"
export function DiscoverRSS3() {
const { data, isLoading } = useAuthQuery(
Queries.discover.rsshubNamespace({
namespace: "rss3",
}),
{
meta: {
persist: true,
},
},
)
if (isLoading) {
return (
<div className="center mt-12 flex w-full flex-col gap-8">
<TokenBrandedRss3 className="size-[50px]" />
<LoadingCircle size="large" />
</div>
)
}
return (
<>
{data?.rss3!.routes && (
<div className="w-full max-w-[540px]">
<DiscoverFeedForm
routePrefix="rss3"
route={data.rss3.routes[Object.keys(data.rss3.routes)[0]!]!}
noDescription
submitButtonClassName="justify-center"
/>
</div>
)}
</>
)
}

View File

@ -107,7 +107,7 @@ export const SocialMediaItem: EntryListItemFC = ({ entryId, entryPreview, transl
as="div"
className={cn(
"prose dark:prose-invert align-middle",
"prose-blockquote:mt-0 cursor-auto select-text text-sm leading-relaxed [&_br:last-child]:hidden",
"prose-blockquote:mt-0 cursor-auto select-text text-sm leading-relaxed",
)}
noMedia
>
@ -120,11 +120,7 @@ export const SocialMediaItem: EntryListItemFC = ({ entryId, entryPreview, transl
{!!media?.length && <SocialMediaGallery media={media} />}
</div>
{showAction && !isMobile && (
<div className="absolute right-1 top-0 -translate-y-1/2 rounded-lg border border-gray-200 bg-white/90 p-1 shadow-sm backdrop-blur-sm dark:border-neutral-900 dark:bg-neutral-900">
<ActionBar entryId={entryId} />
</div>
)}
{showAction && !isMobile && <ActionBar entryId={entryId} />}
</div>
)
}
@ -134,20 +130,25 @@ SocialMediaItem.wrapperClassName = tw`w-[645px] max-w-full m-auto`
const ActionBar = ({ entryId }: { entryId: string }) => {
const { mainAction: entryActions } = useSortedEntryActions({ entryId })
if (entryActions.length === 0) return null
return (
<div className="flex items-center gap-1">
{entryActions
.filter(
(item) => item.id !== COMMAND_ID.entry.read && item.id !== COMMAND_ID.entry.openInBrowser,
)
.map((item) => (
<CommandActionButton
commandId={item.id}
active={item.active}
onClick={item.onClick}
key={item.id}
/>
))}
<div className="absolute right-1 top-0 -translate-y-1/2 rounded-lg border border-gray-200 bg-white/90 p-1 shadow-sm backdrop-blur-sm dark:border-neutral-900 dark:bg-neutral-900">
<div className="flex items-center gap-1">
{entryActions
.filter(
(item) =>
item.id !== COMMAND_ID.entry.read && item.id !== COMMAND_ID.entry.openInBrowser,
)
.map((item) => (
<CommandActionButton
commandId={item.id}
active={item.active}
onClick={item.onClick}
key={item.id}
/>
))}
</div>
</div>
)
}

View File

@ -10,6 +10,7 @@ import { useEffect, useMemo, useRef, useState } from "react"
import { AudioPlayer } from "~/atoms/player"
import { m } from "~/components/common/Motion"
import { RelativeTime } from "~/components/ui/datetime"
import { HTML } from "~/components/ui/markdown/HTML"
import { Media } from "~/components/ui/media"
import { usePreviewMedia } from "~/components/ui/media/hooks"
import type { ModalContentComponent } from "~/components/ui/modal"
@ -19,6 +20,7 @@ import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { FeedIcon } from "~/modules/feed/feed-icon"
import { FeedTitle } from "~/modules/feed/feed-title"
import { useEntryTranslation } from "~/store/ai/hook"
import { useEntry } from "~/store/entry/hooks"
import { GridItem } from "../templates/grid-item-template"
@ -45,7 +47,7 @@ export function VideoItem({ entryId, entryPreview, translation }: UniversalItemP
attachments: entry?.entries.attachments,
}),
],
[entry?.entries.url],
[entry?.entries.attachments, entry?.entries.url],
)
const modalStack = useModalStack()
const previewMedia = usePreviewMedia()
@ -88,7 +90,9 @@ export function VideoItem({ entryId, entryPreview, translation }: UniversalItemP
if (iframeSrc) {
modalStack.present({
title: "",
content: (props) => <PreviewVideoModalContent src={iframeSrc} {...props} />,
content: (props) => (
<PreviewVideoModalContent src={iframeSrc} entryId={entryId} {...props} />
),
clickOutsideToDismiss: true,
CustomModalComponent: PlainModal,
overlay: true,
@ -111,12 +115,12 @@ export function VideoItem({ entryId, entryPreview, translation }: UniversalItemP
isActive && "rounded-b-none",
)}
/>
) : entry.entries.media ? (
) : entry.entries.media?.[0] ? (
<Media
key={entry.entries.media?.[0]!.url}
src={entry.entries.media?.[0]!.url}
type={entry.entries.media?.[0]!.type}
previewImageUrl={entry.entries.media?.[0]!.preview_image_url}
key={entry.entries.media[0].url}
src={entry.entries.media[0].url}
type={entry.entries.media[0].type}
previewImageUrl={entry.entries.media[0].preview_image_url}
className={cn(
"aspect-video w-full shrink-0 rounded-md object-cover",
isActive && "rounded-b-none",
@ -142,7 +146,11 @@ export function VideoItem({ entryId, entryPreview, translation }: UniversalItemP
const PreviewVideoModalContent: ModalContentComponent<{
src: string
}> = ({ dismiss, src }) => {
entryId: string
}> = ({ dismiss, src, entryId }) => {
const entry = useEntry(entryId)
const translation = useEntryTranslation({ entry, extraFields: ["content"] })
const content = translation.data?.content || entry?.entries.content
const currentAudioPlayerIsPlay = useRef(AudioPlayer.get().status === "playing")
useEffect(() => {
const currentValue = currentAudioPlayerIsPlay.current
@ -169,6 +177,13 @@ const PreviewVideoModalContent: ModalContentComponent<{
</m.div>
<ViewTag src={src} className="size-full" />
{!!content && (
<div className="bg-background p-10 pt-5 backdrop-blur-sm">
<HTML as="div" className="prose dark:prose-invert !max-w-full" noMedia>
{content}
</HTML>
</div>
)}
</m.div>
)
}

View File

@ -1,17 +1,12 @@
import { useViewport } from "@follow/components/hooks/useViewport.js"
import { ActionButton, Button, IconButton } from "@follow/components/ui/button/index.js"
import { ActionButton, Button } from "@follow/components/ui/button/index.js"
import { Kbd, KbdCombined } from "@follow/components/ui/kbd/Kbd.js"
import { RootPortal } from "@follow/components/ui/portal/index.jsx"
import { useCountdown } from "@follow/hooks"
import { ELECTRON_BUILD } from "@follow/shared/constants"
import { cn, getOS } from "@follow/utils/utils"
import { AnimatePresence, m } from "framer-motion"
import { cn } from "@follow/utils/utils"
import type { FC, ReactNode } from "react"
import { forwardRef, Fragment, useState } from "react"
import { forwardRef, useState } from "react"
import { useHotkeys } from "react-hotkeys-hook"
import { Trans, useTranslation } from "react-i18next"
import { toast } from "sonner"
import { useOnClickOutside } from "usehooks-ts"
import { HotKeyScopeMap } from "~/constants"
import { shortcuts } from "~/constants/shortcuts"
@ -26,63 +21,49 @@ interface MarkAllButtonProps {
shortcut?: boolean
}
export const MarkAllReadWithOverlay = forwardRef<
HTMLButtonElement,
MarkAllButtonProps & {
containerRef: React.RefObject<HTMLDivElement>
}
>(({ className, which = "all", shortcut, containerRef }, ref) => {
const { t } = useTranslation()
const { t: commonT } = useTranslation("common")
export const MarkAllReadButton = forwardRef<HTMLButtonElement, MarkAllButtonProps>(
({ className, which = "all", shortcut }, ref) => {
const { t } = useTranslation()
const { t: commonT } = useTranslation("common")
const [show, setShow] = useState(false)
const handleMarkAllAsRead = useMarkAllByRoute()
const handleMarkAllAsRead = useMarkAllByRoute()
const [popoverRef, setPopoverRef] = useState<HTMLDivElement | null>(null)
useOnClickOutside({ current: popoverRef }, () => {
setShow(false)
})
useHotkeys(
shortcuts.entries.markAllAsRead.key,
() => {
setShow(false)
let cancel = false
const undo = () => {
toast.dismiss(id)
if (cancel) return
cancel = true
}
const id = toast("", {
description: <ConfirmMarkAllReadInfo undo={undo} />,
duration: 3000,
onAutoClose() {
useHotkeys(
shortcuts.entries.markAllAsRead.key,
() => {
let cancel = false
const undo = () => {
toast.dismiss(id)
if (cancel) return
handleMarkAllAsRead()
},
action: {
label: (
<span className="flex items-center gap-1">
{t("mark_all_read_button.undo")}
<Kbd className="border-border inline-flex items-center border bg-transparent dark:text-white">
Meta+Z
</Kbd>
</span>
),
onClick: undo,
},
})
},
{
preventDefault: true,
scopes: HotKeyScopeMap.Home,
},
)
cancel = true
}
const id = toast("", {
description: <ConfirmMarkAllReadInfo undo={undo} />,
duration: 3000,
onAutoClose() {
if (cancel) return
handleMarkAllAsRead()
},
action: {
label: (
<span className="flex items-center gap-1">
{t("mark_all_read_button.undo")}
<Kbd className="border-border inline-flex items-center border bg-transparent dark:text-white">
Meta+Z
</Kbd>
</span>
),
onClick: undo,
},
})
},
{
preventDefault: true,
scopes: HotKeyScopeMap.Home,
},
)
return (
<Fragment>
return (
<ActionButton
tooltip={
<>
@ -104,88 +85,14 @@ export const MarkAllReadWithOverlay = forwardRef<
className={className}
ref={ref}
onClick={() => {
setShow(true)
handleMarkAllAsRead()
}}
>
<i className="i-mgc-check-circle-cute-re" />
</ActionButton>
<AnimatePresence>
{show && (
<Popup
which={which}
containerRef={containerRef}
setPopoverRef={setPopoverRef}
setShow={setShow}
handleMarkAllAsRead={handleMarkAllAsRead}
/>
)}
</AnimatePresence>
</Fragment>
)
})
const Popup = ({ which, containerRef, setPopoverRef, setShow, handleMarkAllAsRead }) => {
const { t } = useTranslation()
const { t: commonT } = useTranslation("common")
const $parent = containerRef.current!
const rect = $parent.getBoundingClientRect()
const paddingLeft = $parent.offsetLeft
// change popup's width when viewport changes.
useViewport((v) => v.w)
// electron window has pt-[calc(var(--fo-window-padding-top)_-10px)]
const isElectronWindows = ELECTRON_BUILD && getOS() === "Windows"
return (
<RootPortal to={$parent}>
<m.div
ref={setPopoverRef}
initial={{
transform: `translateY(${isElectronWindows ? "-95px" : "-70px"})`,
}}
animate={{
transform: `translateY(${isElectronWindows ? "-10px" : "0px"})`,
}}
exit={{
transform: `translateY(${isElectronWindows ? "-95px" : "-70px"})`,
}}
transition={{ type: "spring", damping: 20, stiffness: 300 }}
className="shadow-modal bg-background lg:bg-theme-modal-background-opaque absolute z-50 shadow"
style={{
left: -paddingLeft,
top: rect.top,
width: rect.width,
}}
>
<div className="flex w-full translate-x-[-2px] items-center justify-between gap-3 !py-3 pl-6 pr-3 [&_button]:text-xs">
<span className="center gap-[calc(0.5rem+2px)]">
<i className="i-mgc-check-circle-cute-re" />
<span className="text-sm font-bold">
<Trans
i18nKey="mark_all_read_button.confirm_mark_all"
components={{
which: <>{commonT(`words.which.${which}` as any)}</>,
}}
/>
</span>
</span>
<div className="space-x-4">
<IconButton
icon={<i className="i-mgc-check-filled" />}
onClick={() => {
handleMarkAllAsRead()
setShow(false)
}}
>
{t("words.confirm")}
</IconButton>
</div>
</div>
</m.div>
</RootPortal>
)
}
)
},
)
const ConfirmMarkAllReadInfo = ({ undo }: { undo: () => any }) => {
const { t } = useTranslation()
@ -235,47 +142,26 @@ export const FlatMarkAllReadButton: FC<
className,
)}
onMouseLeave={() => {
if (status === "confirm") {
setStatus("initial")
}
}}
onClick={() => {
if (status === "confirm") {
handleMarkAll()
.then(() => setStatus("done"))
.catch(() => setStatus("initial"))
return
}
setStatus("confirm")
handleMarkAll()
.then(() => setStatus("done"))
.catch(() => setStatus("initial"))
}}
>
<AnimatePresence mode="wait">
{status === "confirm" ? (
<m.i key={1} {...animate} className={cn("i-mgc-question-cute-re", iconClassName)} />
<i key={2} {...animate} className={cn("i-mgc-check-circle-cute-re", iconClassName)} />
<span className="duration-200">
{status === "done" ? (
t("mark_all_read_button.done")
) : (
<m.i key={2} {...animate} className={cn("i-mgc-check-circle-cute-re", iconClassName)} />
<Trans
i18nKey="mark_all_read_button.mark_as_read"
components={{
which: (
<>{typeof which === "string" ? t.common(`words.which.${which}` as any) : which}</>
),
}}
/>
)}
</AnimatePresence>
<span className={cn(status === "confirm" ? "opacity-0" : "opacity-100", "duration-200")}>
<Trans
i18nKey="mark_all_read_button.mark_as_read"
components={{
which: (
<>{typeof which === "string" ? t.common(`words.which.${which}` as any) : which}</>
),
}}
/>
</span>
<span
className={cn(
"center absolute inset-y-0 left-5 right-0 flex",
status === "confirm" ? "opacity-100" : "opacity-0",
"duration-200",
)}
>
{t("mark_all_read_button.confirm")}
</span>
</Button>
)

View File

@ -7,7 +7,6 @@ import { useIsOnline } from "@follow/hooks"
import { stopPropagation } from "@follow/utils/dom"
import { cn, isBizId } from "@follow/utils/utils"
import type { FC } from "react"
import * as React from "react"
import { useTranslation } from "react-i18next"
import { setGeneralSetting, useGeneralSettingKey } from "~/atoms/settings/general"
@ -20,7 +19,7 @@ import { EntryHeader } from "~/modules/entry-content/header"
import { useRefreshFeedMutation } from "~/queries/feed"
import { useFeedById, useFeedHeaderTitle } from "~/store/feed"
import { MarkAllReadWithOverlay } from "../components/mark-all-button"
import { MarkAllReadButton } from "../components/mark-all-button"
import {
AppendTaildingDivider,
DailyReportButton,
@ -58,13 +57,11 @@ export const EntryListHeader: FC<{
const feed = useFeedById(feedId)
const containerRef = React.useRef<HTMLDivElement>(null)
const titleStyleBasedView = ["pl-6", "pl-7", "pl-7", "pl-7", "px-5", "pl-6"]
const feedColumnShow = useTimelineColumnShow()
return (
<div
ref={containerRef}
className={cn(
"mb-2 flex w-full flex-col pr-4 pt-2.5 transition-[padding] duration-300 ease-in-out",
!feedColumnShow && "macos:mt-4 macos:pt-margin-macos-traffic-light-y",
@ -139,7 +136,7 @@ export const EntryListHeader: FC<{
<i className="i-mgc-round-cute-re" />
)}
</ActionButton>
<MarkAllReadWithOverlay containerRef={containerRef} shortcut />
<MarkAllReadButton shortcut />
</div>
</div>

View File

@ -8,7 +8,6 @@ import { useIsOnline } from "@follow/hooks"
import { stopPropagation } from "@follow/utils/dom"
import { cn, isBizId } from "@follow/utils/utils"
import type { FC } from "react"
import * as React from "react"
import { useTranslation } from "react-i18next"
import { setGeneralSetting, useGeneralSettingKey } from "~/atoms/settings/general"
@ -22,7 +21,7 @@ import { FeedColumnMobile } from "~/modules/app-layout/feed-column/mobile"
import { useRefreshFeedMutation } from "~/queries/feed"
import { useFeedById, useFeedHeaderTitle } from "~/store/feed"
import { MarkAllReadWithOverlay } from "../components/mark-all-button"
import { MarkAllReadButton } from "../components/mark-all-button"
import type { EntryListHeaderProps } from "./EntryListHeader.shared"
import {
AppendTaildingDivider,
@ -61,11 +60,9 @@ export const EntryListHeader: FC<EntryListHeaderProps> = ({ refetch, isRefreshin
const isList = !!listId
const showQuickTimeline = useGeneralSettingKey("showQuickTimeline")
const containerRef = React.useRef<HTMLDivElement>(null)
return (
<div
ref={containerRef}
className={cn(
"flex w-full flex-col pb-2 pr-4 transition-[padding] duration-300 ease-in-out",
"pt-safe-offset-2.5 pl-6",
@ -142,7 +139,7 @@ export const EntryListHeader: FC<EntryListHeaderProps> = ({ refetch, isRefreshin
<i className="i-mgc-round-cute-re" />
)}
</ActionButton>
<MarkAllReadWithOverlay containerRef={containerRef} shortcut />
<MarkAllReadButton shortcut />
</>
)}
</div>

View File

@ -13,6 +13,7 @@ import {
HoverCardPortal,
HoverCardTrigger,
} from "@radix-ui/react-hover-card"
import { debounce } from "es-toolkit/compat"
import type { FC } from "react"
import * as React from "react"
import { useTranslation } from "react-i18next"
@ -125,9 +126,9 @@ export const SwitchToMasonryButton = () => {
max={6}
step={1}
defaultValue={[masonryColumnValue]}
onValueCommit={(value) => {
onValueChange={debounce((value) => {
setMasonryColumnValue(value[0]!)
}}
}, 300)}
/>
</div>
)}

View File

@ -0,0 +1,121 @@
import { AutoResizeHeight } from "@follow/components/ui/auto-resize-height/index.js"
import { cn } from "@follow/utils/utils"
import { useTranslation } from "react-i18next"
import { useShowAISummary } from "~/atoms/ai-summary"
import { useActionLanguage } from "~/atoms/settings/general"
import { CopyButton } from "~/components/ui/button/CopyButton"
import { useAuthQuery } from "~/hooks/common"
import { Queries } from "~/queries"
import { useEntry } from "~/store/entry"
export function AISummary({ entryId }: { entryId: string }) {
const { t } = useTranslation()
const entry = useEntry(entryId)
const showAISummary = useShowAISummary(entry)
const actionLanguage = useActionLanguage()
const summary = useAuthQuery(
Queries.ai.summary({
entryId,
language: actionLanguage,
}),
{
enabled: showAISummary,
refetchOnMount: false,
refetchOnWindowFocus: false,
meta: {
persist: true,
},
},
)
if (!showAISummary || (!summary.isLoading && !summary.data)) {
return null
}
return (
<div
className={cn(
"group relative my-8 overflow-hidden rounded-2xl border border-neutral-200/50 p-5 backdrop-blur-xl",
"bg-gradient-to-b from-neutral-50/80 to-white/40 dark:from-neutral-900/80 dark:to-neutral-900/40",
"dark:border-neutral-800/50",
summary.isLoading &&
"before:absolute before:inset-0 before:-z-10 before:animate-[pulse_2s_cubic-bezier(0.4,0,0.6,1)_infinite] before:bg-gradient-to-r before:from-purple-100/0 before:via-purple-300/10 before:to-purple-100/0 dark:before:from-purple-900/0 dark:before:via-purple-600/10 dark:before:to-purple-900/0",
)}
>
<div
className={cn(
"absolute inset-0 -z-10 bg-gradient-to-br opacity-50",
"from-purple-100/20 via-transparent to-blue-100/20",
"dark:from-purple-900/20 dark:to-blue-900/20",
summary.isLoading && "animate-[glow_4s_ease-in-out_infinite]",
)}
/>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{/* Glowing AI icon */}
<div className="center relative">
<i
className={cn(
"i-mgc-magic-2-cute-re text-lg",
summary.isLoading
? "text-purple-500/70 dark:text-purple-400/70"
: "text-purple-600 dark:text-purple-400",
)}
/>
<div
className={cn(
"absolute inset-0 rounded-full blur-sm",
summary.isLoading
? "animate-[pulse_2s_infinite] bg-purple-400/30 dark:bg-purple-500/30"
: "animate-pulse bg-purple-400/20 dark:bg-purple-500/20",
)}
/>
</div>
<span
className={cn(
"bg-gradient-to-r bg-clip-text font-medium text-transparent",
summary.isLoading
? "from-purple-500/70 to-blue-500/70 dark:from-purple-400/70 dark:to-blue-400/70"
: "from-purple-600 to-blue-600 dark:from-purple-400 dark:to-blue-400",
)}
>
{t("entry_content.ai_summary")}
</span>
</div>
{summary.data && (
<CopyButton
value={summary.data}
variant="outline"
className={cn(
"!bg-white/10 !text-purple-600 dark:!text-purple-400",
"hover:!bg-white/20 dark:hover:!bg-neutral-800/30",
"!border-purple-200/30 dark:!border-purple-800/30",
"sm:opacity-0 sm:duration-300 sm:group-hover:translate-y-0 sm:group-hover:opacity-100",
"backdrop-blur-sm",
)}
/>
)}
</div>
<AutoResizeHeight
spring
className="mt-4 text-sm leading-relaxed text-neutral-700 dark:text-neutral-300"
>
{summary.isLoading ? (
<div className="space-y-2">
<div className="h-3 w-full rounded-lg bg-neutral-200/30 dark:bg-neutral-700/30" />
<div className="h-3 w-[92%] rounded-lg bg-neutral-200/30 dark:bg-neutral-700/30" />
<div className="h-3 w-[85%] rounded-lg bg-neutral-200/30 dark:bg-neutral-700/30" />
</div>
) : (
<div className="animate-in fade-in-0 duration-500">{summary.data}</div>
)}
</AutoResizeHeight>
</div>
)
}

View File

@ -1,35 +0,0 @@
import { ActionButton } from "@follow/components/ui/button/index.js"
import type { MediaModel } from "@follow/models"
import { tracker } from "@follow/tracker"
import { useTranslation } from "react-i18next"
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { useEntry } from "~/store/entry"
import { ImageGallery } from "./picture-gallery"
export const ImageGalleryAction = ({ id }: { id: string }) => {
const { t } = useTranslation()
const images = useEntry(id, (entry) => entry.entries.media)
const { present } = useModalStack()
if (images?.length && images.length > 5) {
return (
<ActionButton
onClick={() => {
tracker.entryContentHeaderImageGalleryClick({
feedId: id,
})
present({
title: t("entry_actions.image_gallery"),
content: () => <ImageGallery images={images as any as MediaModel[]} />,
max: true,
clickOutsideToDismiss: true,
})
}}
icon={<i className="i-mgc-pic-cute-fi" />}
tooltip={t("entry_actions.image_gallery")}
/>
)
}
return null
}

View File

@ -1,4 +1,5 @@
import { ActionButton } from "@follow/components/ui/button/index.js"
import { RootPortal } from "@follow/components/ui/portal/index.js"
import type { FeedViewType } from "@follow/constants"
import { useMemo } from "react"
@ -41,30 +42,32 @@ export const MoreActions = ({ entryId, view }: { entryId: string; view?: FeedVie
<DropdownMenuTrigger asChild>
<ActionButton icon={<i className="i-mgc-more-1-cute-re" />} />
</DropdownMenuTrigger>
<DropdownMenuContent>
{availableActions.map((config) => (
<CommandDropdownMenuItem
key={config.id}
commandId={config.id}
onClick={config.onClick}
active={config.active}
/>
))}
{availableActions.length > 0 && <DropdownMenuSeparator />}
{extraAction.map((config) => (
<CommandDropdownMenuItem
key={config.id}
commandId={config.id}
onClick={config.onClick}
active={config.active}
/>
))}
</DropdownMenuContent>
<RootPortal>
<DropdownMenuContent>
{availableActions.map((config) => (
<CommandDropdownMenuItem
key={config.id}
commandId={config.id}
onClick={config.onClick}
active={config.active}
/>
))}
{availableActions.length > 0 && <DropdownMenuSeparator />}
{extraAction.map((config) => (
<CommandDropdownMenuItem
key={config.id}
commandId={config.id}
onClick={config.onClick}
active={config.active}
/>
))}
</DropdownMenuContent>
</RootPortal>
</DropdownMenu>
)
}
const CommandDropdownMenuItem = ({
export const CommandDropdownMenuItem = ({
commandId,
onClick,
active,

View File

@ -52,7 +52,7 @@ const Render: React.ComponentType<
</MasonryItemFixedDimensionWrapper>
)
}
export const ImageGallery = ({ images }: { images: MediaModel[] }) => {
export const ImageGallery = ({ images }: { images: MediaModel }) => {
const { containerRef, currentColumn, currentItemWidth } = useMasonryColumn(gutter)
const [masonryItemsRadio, setMasonryItemsRadio] = useState<Record<string, number>>({})
@ -62,13 +62,13 @@ export const ImageGallery = ({ images }: { images: MediaModel[] }) => {
<MasonryItemsAspectRatioContext.Provider value={masonryItemsRadio}>
<MasonryItemsAspectRatioSetterContext.Provider value={setMasonryItemsRadio}>
<MediaContainerWidthProvider width={currentItemWidth}>
<Masonry<(typeof images)[number]>
items={images}
<Masonry
items={images ?? []}
columnGutter={gutter}
columnWidth={currentItemWidth}
columnCount={currentColumn}
overscanBy={2}
render={Render as any}
render={Render}
/>
</MediaContainerWidthProvider>
</MasonryItemsAspectRatioSetterContext.Provider>

View File

@ -22,15 +22,6 @@ export const [
setEntryContentScrollToTop,
] = createAtomHooks(atom(false))
export const [
,
,
useEntryContentPlaceholderLogoShow,
,
getEntryContentPlaceholderLogoShow,
setEntryContentPlaceholderLogoShow,
] = createAtomHooks(atom(true))
export const [, , , , getTranslationCache, setTranslationCache] = createAtomHooks(
atomWithStorage(getStorageNS("translation-cache"), {} as Record<string, string>),
)

View File

@ -1,24 +1,18 @@
import { Logo } from "@follow/components/icons/logo.jsx"
import { stopPropagation } from "@follow/utils/dom"
import { cn } from "@follow/utils/utils"
import { useFeedHeaderTitle } from "~/store/feed"
import { useEntryContentPlaceholderLogoShow } from "../atoms"
export const EntryPlaceholderLogo = () => {
const title = useFeedHeaderTitle()
const logoShow = useEntryContentPlaceholderLogoShow()
return (
<div
data-hide-in-print
onContextMenu={stopPropagation}
className={cn(
"flex w-full min-w-0 flex-col items-center justify-center gap-1 px-12 pb-6 text-center text-lg font-medium text-zinc-400 duration-500",
!logoShow && "translate-y-[-50px] opacity-0",
)}
className={
"flex w-full min-w-0 flex-col items-center justify-center gap-1 px-12 pb-6 text-center text-lg font-medium text-zinc-400 duration-500"
}
>
<Logo className="size-16 opacity-40 grayscale" />
<div className="line-clamp-3 w-[60ch] max-w-full">{title}</div>

View File

@ -1,10 +1,10 @@
import { cn, formatEstimatedMins, formatTimeToSeconds } from "@follow/utils/utils"
import dayjs from "dayjs"
import { formatEstimatedMins, formatTimeToSeconds } from "@follow/utils/utils"
import { useMemo } from "react"
import { useUISettingKey } from "~/atoms/settings/ui"
import { useWhoami } from "~/atoms/user"
import { RelativeTime } from "~/components/ui/datetime"
import { useNavigateEntry } from "~/hooks/biz/useNavigateEntry"
import { FeedIcon } from "~/modules/feed/feed-icon"
import { useEntryTranslation } from "~/store/ai/hook"
import { useEntry, useEntryReadHistory } from "~/store/entry"
@ -53,12 +53,6 @@ export const EntryTitle = ({ entryId, compact }: EntryLinkProps) => {
const dateFormat = useUISettingKey("dateFormat")
const dateRender = useMemo(() => {
if (!entry?.entries.publishedAt) return null
if (dateFormat === "default") return new Date(entry.entries.publishedAt).toLocaleString()
return dayjs(entry.entries.publishedAt).format(dateFormat)
}, [dateFormat, entry?.entries.publishedAt])
const audioAttachment = useMemo(() => {
return entry?.entries?.attachments?.find((a) => a.mime_type?.startsWith("audio") && a.url)
}, [entry?.entries?.attachments])
@ -76,6 +70,8 @@ export const EntryTitle = ({ entryId, compact }: EntryLinkProps) => {
return formatEstimatedMins(Math.floor(durationInSeconds / 60))
}, [audioAttachment])
const navigateEntry = useNavigateEntry()
if (!entry) return null
return compact ? (
@ -91,49 +87,66 @@ export const EntryTitle = ({ entryId, compact }: EntryLinkProps) => {
</div>
</div>
) : (
<a
href={populatedFullHref || void 0}
target="_blank"
draggable="false"
className="cursor-button hover:bg-theme-item-hover focus-visible:bg-theme-item-hover @sm:-mx-3 @sm:p-3 block min-w-0 rounded-lg transition-colors focus-visible:!outline-none lg:-mx-6 lg:p-6"
rel="noreferrer"
onClick={(e) => {
if (window.getSelection()?.toString()) {
e.preventDefault()
}
}}
>
<div className={cn("select-text break-words font-bold", compact ? "text-2xl" : "text-3xl")}>
<EntryTranslation
source={entry.entries.title}
target={translation.data?.title}
className="select-text hyphens-auto"
/>
</div>
<div className="mt-2 text-[13px] font-medium text-zinc-500">
{getPreferredTitle(feed || inbox, entry.entries)}
</div>
<div className="flex select-none items-center gap-2 text-[13px] text-zinc-500">
{dateRender}
<div className="group relative block min-w-0 rounded-lg">
<div className="flex flex-col gap-3">
<div>
<a
href={populatedFullHref ?? "#"}
target="_blank"
rel="noopener noreferrer"
className="cursor-link select-text break-words text-[1.7rem] font-bold leading-normal hover:opacity-80"
>
<EntryTranslation
source={entry.entries.title}
target={translation.data?.title}
className="inline-block select-text hyphens-auto duration-200"
/>
</a>
</div>
{estimatedMins && (
<div className="flex items-center gap-1">
<i className="i-mgc-time-cute-re" />
<span>{estimatedMins}</span>
{/* Meta Information with improved layout */}
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 text-sm">
<div className="flex flex-wrap items-center gap-4 text-gray-500 dark:text-gray-400">
<div
className="flex items-center text-xs font-medium text-zinc-800/90 duration-200 hover:text-zinc-900 dark:text-zinc-300/90 dark:hover:text-zinc-100"
onClick={() =>
navigateEntry({
feedId: feed?.id,
})
}
>
<FeedIcon fallback feed={feed || inbox} entry={entry.entries} size={16} />
{getPreferredTitle(feed || inbox, entry.entries)}
</div>
<div className="flex items-center gap-1.5 transition-colors hover:text-gray-700 dark:hover:text-gray-300">
<i className="i-mgc-calendar-time-add-cute-re text-base" />
<span className="text-xs tabular-nums">
<RelativeTime date={entry.entries.publishedAt} dateFormatTemplate={dateFormat} />
</span>
</div>
{estimatedMins && (
<div className="flex items-center gap-1.5 transition-colors hover:text-gray-700 dark:hover:text-gray-300">
<i className="i-mgc-time-cute-re text-base" />
<span className="text-xs tabular-nums">{estimatedMins}</span>
</div>
)}
{(() => {
const readCount =
(entryHistory?.readCount ?? 0) +
(entryHistory?.userIds?.every((id) => id !== user?.id) ? 1 : 0)
return readCount > 0 ? (
<div className="flex items-center gap-1.5 transition-colors hover:text-gray-700 dark:hover:text-gray-300">
<i className="i-mgc-eye-2-cute-re text-base" />
<span className="text-xs tabular-nums">{readCount.toLocaleString()}</span>
</div>
) : null
})()}
</div>
)}
<div className="flex items-center gap-1">
<i className="i-mgc-eye-2-cute-re" />
<span>
{(
(entryHistory?.readCount ?? 0) +
(entryHistory?.userIds?.every((id) => id !== user?.id) ? 1 : 0)
) // if no me, +1
.toLocaleString()}
</span>
</div>
</div>
</a>
</div>
)
}

View File

@ -0,0 +1,10 @@
import { useEntry } from "~/store/entry"
import { ImageGallery } from "../actions/picture-gallery"
export const ImageGalleryContent = ({ entryId }: { entryId: string }) => {
const images = useEntry(entryId, (entry) => entry.entries.media)
// images?.length && images.length > 5
// We don't need to check here, we already check in the action
return <ImageGallery images={images} />
}

View File

@ -8,7 +8,6 @@ import { useEntry } from "~/store/entry/hooks"
import { ElectronAdditionActions } from "./actions/electron-actions"
import { EntryHeaderActions } from "./actions/header-actions"
import { ImageGalleryAction } from "./actions/image-gallery-actions"
import { MoreActions } from "./actions/more-actions"
import { useEntryContentScrollToTop, useEntryTitleMeta } from "./atoms"
import { EntryReadHistory } from "./components/EntryReadHistory"
@ -21,7 +20,7 @@ function EntryHeaderImpl({ view, entryId, className, compact }: EntryHeaderProps
const hideRecentReader = useUISettingKey("hideRecentReader")
const shouldShowMeta = (hideRecentReader || !isAtTop) && !!entryTitleMeta?.title
const shouldShowMeta = !isAtTop && !!entryTitleMeta?.title
if (!entry?.entries) return null
@ -29,8 +28,8 @@ function EntryHeaderImpl({ view, entryId, className, compact }: EntryHeaderProps
<div
data-hide-in-print
className={cn(
"zen-mode-macos:ml-margin-macos-traffic-light-x relative flex min-w-0 items-center justify-between gap-3 overflow-hidden border-b border-transparent text-lg text-zinc-500 duration-200",
shouldShowMeta && "border-border",
"zen-mode-macos:ml-margin-macos-traffic-light-x relative flex min-w-0 items-center justify-between gap-3 overflow-hidden text-lg text-zinc-500 duration-200",
shouldShowMeta && "border-border border-b",
className,
)}
>
@ -74,7 +73,6 @@ function EntryHeaderImpl({ view, entryId, className, compact }: EntryHeaderProps
<div className="relative flex shrink-0 items-center justify-end gap-2">
{!compact && <ElectronAdditionActions view={view} entry={entry} key={entry.entries.id} />}
<ImageGalleryAction id={entry.entries.id} />
<EntryHeaderActions entryId={entry.entries.id} view={view} />
<MoreActions entryId={entry.entries.id} />
</div>

View File

@ -1,5 +1,12 @@
import { tracker } from "@follow/tracker"
import { EventBus } from "@follow/utils/event-bus"
import { useEffect } from "react"
import { createElement, useCallback, useEffect } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { useModalStack } from "~/components/ui/modal/stacked/hooks"
import { ImageGalleryContent } from "./components/ImageGalleryContent"
declare module "@follow/utils/event-bus" {
export interface CustomEvent {
@ -14,3 +21,27 @@ export const useFocusEntryContainerSubscriptions = (ref: React.RefObject<HTMLDiv
})
}, [ref])
}
export const useGalleryModal = () => {
const { present } = useModalStack()
const { t } = useTranslation()
return useCallback(
(entryId?: string) => {
if (!entryId) {
// this should not happen unless there is a bug in the code
toast.error("Invalid feed id")
return
}
tracker.entryContentHeaderImageGalleryClick({
feedId: entryId,
})
present({
title: t("entry_actions.image_gallery"),
content: () => createElement(ImageGalleryContent, { entryId }),
max: true,
clickOutsideToDismiss: true,
})
},
[present, t],
)
}

View File

@ -23,6 +23,7 @@ import { useFeedById } from "~/store/feed"
import { useInboxById } from "~/store/inbox"
import { EntryContentHTMLRenderer } from "../renderer/html"
import { AISummary } from "./AISummary"
import { EntryTimelineSidebar } from "./components/EntryTimelineSidebar"
import { EntryTitle } from "./components/EntryTitle"
import { SourceContentPanel } from "./components/SourceContentView"
@ -31,7 +32,6 @@ import { EntryHeader } from "./header"
import { useFocusEntryContainerSubscriptions } from "./hooks"
import type { EntryContentProps } from "./index.shared"
import {
AISummary,
ContainerToc,
NoContent,
ReadabilityAutoToggleEffect,
@ -116,6 +116,8 @@ export const EntryContent: Component<EntryContentProps> = ({
const contentTranslated = useEntryTranslation({ entry, extraFields: ["content"] })
const isInPeekModal = useInPeekModal()
if (!entry) return null
const entryContent = entry?.entries.content ?? data?.entries.content
@ -126,12 +128,14 @@ export const EntryContent: Component<EntryContentProps> = ({
return (
<>
<EntryHeader
entryId={entry.entries.id}
view={view}
className={cn("@container h-[55px] shrink-0 px-3", classNames?.header)}
compact={compact}
/>
{!isInPeekModal && (
<EntryHeader
entryId={entry.entries.id}
view={view}
className={cn("@container h-[55px] shrink-0 px-3", classNames?.header)}
compact={compact}
/>
)}
<div className="@container relative flex size-full flex-col overflow-hidden print:size-auto print:overflow-visible">
<EntryTimelineSidebar entryId={entry.entries.id} />

View File

@ -22,11 +22,12 @@ import { useInboxById } from "~/store/inbox"
import { CornerPlayer } from "../player/corner-player"
import { EntryContentHTMLRenderer } from "../renderer/html"
import { AISummary } from "./AISummary"
import { EntryReadHistory } from "./components/EntryReadHistory"
import { EntryTitle } from "./components/EntryTitle"
import { SupportCreator } from "./components/SupportCreator"
import { EntryHeader } from "./header"
import { AISummary, NoContent, RenderError, TitleMetaHandler } from "./index.shared"
import { NoContent, RenderError, TitleMetaHandler } from "./index.shared"
import { EntryContentLoading } from "./loading"
export interface EntryContentClassNames {

View File

@ -1,6 +1,5 @@
import { getViewport } from "@follow/components/hooks/useViewport.js"
import { CircleProgress } from "@follow/components/icons/Progress.js"
import { AutoResizeHeight } from "@follow/components/ui/auto-resize-height/index.js"
import { Button, MotionButtonBase } from "@follow/components/ui/button/index.js"
import { LoadingWithIcon } from "@follow/components/ui/loading/index.jsx"
import { RootPortal } from "@follow/components/ui/portal/index.jsx"
@ -14,27 +13,22 @@ import type { FC } from "react"
import { memo, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { useShowAISummary } from "~/atoms/ai-summary"
import {
ReadabilityStatus,
setReadabilityStatus,
useEntryInReadabilityStatus,
useEntryReadabilityContent,
} from "~/atoms/readability"
import { useActionLanguage } from "~/atoms/settings/general"
import { enableShowSourceContent } from "~/atoms/source-content"
import { CopyButton } from "~/components/ui/button/CopyButton"
import { Toc } from "~/components/ui/markdown/components/Toc"
import { useEntryReadabilityToggle } from "~/hooks/biz/useEntryActions"
import { useRouteParamsSelector } from "~/hooks/biz/useRouteParams"
import { useAuthQuery } from "~/hooks/common/useBizQuery"
import { getNewIssueUrl } from "~/lib/issues"
import {
useIsSoFWrappedElement,
useWrappedElement,
useWrappedElementSize,
} from "~/providers/wrapped-element-provider"
import { Queries } from "~/queries"
import { useEntry } from "~/store/entry"
import { useFeedById } from "~/store/feed"
import { useInboxById } from "~/store/inbox"
@ -314,48 +308,3 @@ const BackTopIndicator: Component = memo(({ className }) => {
</span>
)
})
export function AISummary({ entryId }: { entryId: string }) {
const { t } = useTranslation()
const entry = useEntry(entryId)
const showAISummary = useShowAISummary(entry)
const actionLanguage = useActionLanguage()
const summary = useAuthQuery(
Queries.ai.summary({
entryId,
language: actionLanguage,
}),
{
enabled: showAISummary,
refetchOnMount: false,
refetchOnWindowFocus: false,
meta: {
persist: true,
},
},
)
if (!showAISummary || (!summary.isLoading && !summary.data)) {
return null
}
return (
<div className="group my-8 space-y-1 rounded-lg border px-4 py-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 font-medium text-zinc-800 dark:text-neutral-400">
<i className="i-mgc-magic-2-cute-re align-middle" />
<span>{t("entry_content.ai_summary")}</span>
</div>
{summary.data && (
<CopyButton
value={summary.data}
className="sm:opacity-0 sm:duration-200 sm:group-hover:opacity-100"
/>
)}
</div>
<AutoResizeHeight spring className="text-sm leading-relaxed">
{summary.isLoading ? SummaryLoadingSkeleton : summary.data}
</AutoResizeHeight>
</div>
)
}

View File

@ -112,10 +112,6 @@ export function FeedIcon({
? entry?.media?.find((i) => i.type === "photo")?.url || entry?.authorAvatar
: entry?.authorAvatar) || feed?.image
if (!feed && !siteUrl) {
throw new Error("You must provide either a feed or a siteUrl")
}
const colors = useMemo(
() => getBackgroundGradient(feed?.title || (feed as FeedModel)?.url || siteUrl || ""),
[feed?.title, (feed as FeedModel)?.url, siteUrl],

View File

@ -64,7 +64,8 @@ export function EmailManagement() {
<AnimatedCommandButton
icon={<m.i className="i-mgc-edit-cute-re size-4" />}
className="bg-theme-item-active size-5 p-1"
className="size-5 p-1"
variant="ghost"
onClick={() => {
present({
title: t("profile.email.change"),

View File

@ -43,6 +43,7 @@ export const SettingAbout = () => {
</span>
)}
<CopyButton
variant="outline"
value={
rendererVersion
? `${currentEnvironment}\n**Renderer**: ${rendererVersion}`

View File

@ -71,16 +71,16 @@ export const SettingAppearance = () => {
{
type: "title",
value: t("appearance.unread_count"),
value: t("appearance.unread_count.label"),
},
defineItem("showDockBadge", {
label: t("appearance.show_dock_badge.label"),
label: t("appearance.unread_count.badge.label"),
hide: !IN_ELECTRON || !["macOS", "Linux"].includes(getOS()) || isMobile,
}),
defineItem("sidebarShowUnreadCount", {
label: t("appearance.sidebar_show_unread_count.label"),
label: t("appearance.unread_count.view_and_subscription.label"),
}),
{

View File

@ -1,5 +1,6 @@
import { Divider } from "@follow/components/ui/divider/index.js"
import {
SimpleIconsCubox,
SimpleIconsEagle,
SimpleIconsInstapaper,
SimpleIconsObsidian,
@ -174,6 +175,41 @@ export const SettingIntegration = () => {
type: "password",
description: t("integration.readeck.token.description"),
}),
{
type: "title",
value: (
<span className="flex items-center gap-2 font-bold">
<SimpleIconsCubox />
{t("integration.cubox.title")}
</span>
),
},
defineSettingItem("enableCubox", {
label: t("integration.cubox.enable.label"),
description: t("integration.cubox.enable.description"),
}),
defineSettingItem("cuboxToken", {
label: t("integration.cubox.token.label"),
vertical: true,
type: "password",
description: (
<>
{t("integration.cubox.token.description")}{" "}
<a
target="_blank"
className="underline"
rel="noreferrer noopener"
href="https://cubox.pro/my/settings/extensions"
>
https://cubox.pro/my/settings/extensions
</a>
</>
),
}),
defineSettingItem("enableCuboxAutoMemo", {
label: t("integration.cubox.autoMemo.label"),
description: t("integration.cubox.autoMemo.description"),
}),
BottomTip,
]}
/>

View File

@ -1,16 +1,26 @@
import { repository } from "@pkg"
import { useTranslation } from "react-i18next"
import { ActionSetting } from "~/modules/action/action-setting"
import { useSubViewTitle } from "~/modules/app-layout/subview/hooks"
export function Component() {
const { t } = useTranslation()
const { t } = useTranslation("common")
useSubViewTitle("words.actions")
return (
<div className="relative flex w-full flex-col items-center gap-8 px-4 pb-8 lg:pb-4">
<div className="text-2xl font-bold">{t("words.actions")}</div>
<a
href={`${repository.url}/wiki/Actions`}
target="_blank"
rel="noreferrer"
className="text-accent border-accent flex items-center gap-2 rounded-full border px-2 py-0.5 text-sm"
>
<i className="i-mgc-book-6-cute-re" />
<span>{t("words.documentation")}</span>
</a>
<ActionSetting />
</div>
)

View File

@ -15,7 +15,6 @@ import { DiscoverForm } from "~/modules/discover/form"
import { DiscoverImport } from "~/modules/discover/import"
import { DiscoverInboxList } from "~/modules/discover/inbox-list-form"
import { Recommendations } from "~/modules/discover/recommendations"
import { DiscoverRSS3 } from "~/modules/discover/rss3-form"
import { DiscoverTransform } from "~/modules/discover/transform-form"
import { DiscoverUser } from "~/modules/discover/user-form"
@ -41,10 +40,6 @@ const tabs: {
value: "inbox",
disableForTrial: true,
},
{
name: "words.rss3",
value: "rss3",
},
{
name: "words.user",
value: "user",
@ -128,7 +123,6 @@ export function Component() {
const TabComponent: Record<string, React.FC<{ type?: string; isInit?: boolean }>> = {
import: DiscoverImport,
rss3: DiscoverRSS3,
inbox: DiscoverInboxList,
user: DiscoverUser,
default: DiscoverForm,

View File

@ -72,7 +72,7 @@ function List({ data }: { data?: RSSHubModel[] }) {
<TableHeader>
<TableRow>
<TableHead className="font-bold" size="sm" />
<TableHead className="w-[150px] font-bold" size="sm">
<TableHead className="w-[190px] font-bold" size="sm">
{t("rsshub.table.owner")}
</TableHead>
<TableHead className="font-bold" size="sm">
@ -118,86 +118,119 @@ function List({ data }: { data?: RSSHubModel[] }) {
)}
</TableCell>
</TableRow>
{data?.map((instance) => {
return (
<TableRow key={instance.id}>
<TableCell className="text-nowrap">
{(() => {
const flag: string[] = []
if (status?.data?.usage?.rsshubId === instance.id) {
flag.push(t("rsshub.table.inuse"))
}
if (instance.ownerUserId === me?.id) {
flag.push(t("rsshub.table.yours"))
}
return flag.join(" / ")
})()}
</TableCell>
<TableCell>
<UserAvatar
userId={instance.ownerUserId}
className="h-auto justify-start p-0"
avatarClassName="size-6"
/>
</TableCell>
<TableCell>
<EllipsisTextWithTooltip className="line-clamp-2">
{instance.description}
</EllipsisTextWithTooltip>
</TableCell>
<TableCell>
<span className="flex items-center justify-end gap-1">
{instance.price} <i className="i-mgc-power text-accent" />
</span>
</TableCell>
<TableCell className="text-right">{instance.userCount}</TableCell>
<TableCell className="text-right">
{instance.userLimit === null
? t("rsshub.table.unlimited")
: instance.userLimit > 1
? instance.userLimit
: t("rsshub.table.private")}
</TableCell>
<TableCell className="text-right">
<div className="flex w-max items-center gap-2">
<Button
className="shrink-0"
variant={status?.data?.usage?.rsshubId === instance.id ? "outline" : "primary"}
onClick={() => {
present({
title: t("rsshub.useModal.title"),
content: ({ dismiss }) => (
<SetModalContent dismiss={dismiss} instance={instance} />
),
})
}}
>
{t(
status?.data?.usage?.rsshubId === instance.id
? "rsshub.table.inuse"
: "rsshub.table.use",
)}
</Button>
{me?.id === instance.ownerUserId && (
{data
?.sort((a, b) => {
// in use first
if (status?.data?.usage?.rsshubId === a.id) {
return -1
}
if (status?.data?.usage?.rsshubId === b.id) {
return 1
}
const loadA = Math.min((a.userCount ?? 0) / (a.userLimit ?? Infinity), 1)
const loadB = Math.min((b.userCount ?? 0) / (b.userLimit ?? Infinity), 1)
// full load last
if (loadA === 1 && loadB === 1) {
return a.price - b.price
}
if (loadA === 1) {
return 1
}
if (loadB === 1) {
return -1
}
return a.price - b.price || loadA - loadB
})
.map((instance) => {
return (
<TableRow key={instance.id}>
<TableCell className="text-nowrap">
{(() => {
const flag: string[] = []
if (status?.data?.usage?.rsshubId === instance.id) {
flag.push(t("rsshub.table.inuse"))
}
if (instance.ownerUserId === me?.id) {
flag.push(t("rsshub.table.yours"))
}
return flag.join(" / ")
})()}
</TableCell>
<TableCell>
<UserAvatar
userId={instance.ownerUserId}
className="h-auto justify-start p-0"
avatarClassName="size-6"
/>
</TableCell>
<TableCell>
<EllipsisTextWithTooltip className="line-clamp-2">
{instance.description}
</EllipsisTextWithTooltip>
</TableCell>
<TableCell>
<span className="flex items-center justify-end gap-1">
{instance.price} <i className="i-mgc-power text-accent" />
</span>
</TableCell>
<TableCell className="text-right">{instance.userCount}</TableCell>
<TableCell className="text-right">
{instance.userLimit === null
? t("rsshub.table.unlimited")
: instance.userLimit > 1
? instance.userLimit
: t("rsshub.table.private")}
</TableCell>
<TableCell className="text-right">
<div className="flex w-max items-center gap-2">
<Button
variant="outline"
onClick={() =>
className="shrink-0"
disabled={
instance.userCount && instance.userLimit
? instance.userCount >= instance.userLimit
: false
}
variant={
status?.data?.usage?.rsshubId === instance.id ? "outline" : "primary"
}
onClick={() => {
present({
title: t("rsshub.table.edit"),
title: t("rsshub.useModal.title"),
content: ({ dismiss }) => (
<AddModalContent dismiss={dismiss} instance={instance} />
<SetModalContent dismiss={dismiss} instance={instance} />
),
})
}
}}
>
{t("rsshub.table.edit")}
{t(
status?.data?.usage?.rsshubId === instance.id
? "rsshub.table.inuse"
: "rsshub.table.use",
)}
</Button>
)}
</div>
</TableCell>
</TableRow>
)
})}
{me?.id === instance.ownerUserId && (
<Button
variant="outline"
onClick={() =>
present({
title: t("rsshub.table.edit"),
content: ({ dismiss }) => (
<AddModalContent dismiss={dismiss} instance={instance} />
),
})
}
>
{t("rsshub.table.edit")}
</Button>
)}
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)

View File

@ -1,4 +1,5 @@
/* eslint-disable react-refresh/only-export-components */
import { useScrollViewElement } from "@follow/components/ui/scroll-area/hooks.js"
import { cn } from "@follow/utils/utils"
import { createContextState } from "foxact/create-context-state"
import { useIsomorphicLayoutEffect } from "foxact/use-isomorphic-layout-effect"
@ -112,6 +113,7 @@ const BoundingDetection: Component<{
const endSetter = useSetIsEOfElement()
const startSetter = useSetIsSOfElement()
const ref = useRef<HTMLDivElement>(null)
const $scrollArea = useScrollViewElement()
useEffect(() => {
if (!ref.current) return
const $el = ref.current
@ -124,6 +126,7 @@ const BoundingDetection: Component<{
},
{
rootMargin: "0px 0px 0px 0px",
root: $scrollArea,
},
)
@ -132,7 +135,7 @@ const BoundingDetection: Component<{
observer.unobserve($el)
observer.disconnect()
}
}, [])
}, [$scrollArea, bounding, endSetter, startSetter])
return <div ref={ref} />
}

View File

@ -37,7 +37,7 @@ export async function registerWebPushNotifications() {
await apiClient.messaging.$post({
json: {
token,
channel: "desktop",
channel: "web",
},
})

View File

@ -579,9 +579,13 @@ class EntryActions {
})
tx.optimistic((entry, ctx) => {
const { inboxId } = entry
const { inboxId, read } = entry
const fullInboxId = `inbox-${inboxId}`
if (!read) {
feedUnreadActions.incrementByFeedId(inboxId, -1)
}
set((state) => {
const nextFlatMapEntries = { ...state.flatMapEntries }
@ -618,6 +622,11 @@ class EntryActions {
})
tx.rollback((entry, ctx) => {
const { inboxId, read } = entry
if (!read) {
feedUnreadActions.incrementByFeedId(inboxId, 1)
}
set((state) => ({
...state,
entries: {

View File

@ -3,7 +3,12 @@ import type { FeedModel, FeedOrListRespModel, InboxModel, ListModel } from "@fol
import { useCallback } from "react"
import { useTranslation } from "react-i18next"
import { FEED_COLLECTION_LIST, ROUTE_FEED_IN_FOLDER, ROUTE_FEED_PENDING } from "~/constants"
import {
FEED_COLLECTION_LIST,
ROUTE_FEED_IN_FOLDER,
ROUTE_FEED_IN_INBOX,
ROUTE_FEED_PENDING,
} from "~/constants"
import { useRouteParams } from "~/hooks/biz/useRouteParams"
import { useInboxStore } from "../inbox"
@ -87,6 +92,9 @@ export const useFeedHeaderTitle = () => {
if (currentFeedId?.startsWith(ROUTE_FEED_IN_FOLDER)) {
return currentFeedId.replace(ROUTE_FEED_IN_FOLDER, "")
}
if (currentFeedId?.startsWith(ROUTE_FEED_IN_INBOX)) {
return currentFeedId.replace(ROUTE_FEED_IN_INBOX, "")
}
return feedTitle || listTitle
}
}

View File

@ -1,6 +1,7 @@
import type { FeedViewType } from "@follow/constants"
import { setFeedUnreadDirty } from "~/atoms/feed"
import { INBOX_PREFIX_ID } from "~/constants"
import { apiClient } from "~/lib/api-fetch"
import { FeedUnreadService } from "~/services"
@ -74,17 +75,25 @@ class FeedUnreadActions {
* @returns previous value
*/
incrementByFeedId(feedId: string, inc: number) {
const finalFeedId = feedId.startsWith(INBOX_PREFIX_ID)
? feedId.slice(INBOX_PREFIX_ID.length)
: feedId
const state = get()
const cur = state.data[feedId]
const cur = state.data[finalFeedId]
const nextValue = Math.max(0, (cur || 0) + inc)
this.internal_setValue([[feedId, nextValue]])
setFeedUnreadDirty(feedId)
this.internal_setValue([[finalFeedId, nextValue]])
setFeedUnreadDirty(finalFeedId)
return cur
}
updateByFeedId(feedId: string, unread: number) {
this.internal_setValue([[feedId, unread]])
const finalFeedId = feedId.startsWith(INBOX_PREFIX_ID)
? feedId.slice(INBOX_PREFIX_ID.length)
: feedId
this.internal_setValue([[finalFeedId, unread]])
}
subscribeUnreadCount(fn: (count: number) => void, immediately?: boolean) {

View File

@ -32,6 +32,12 @@ export default resolveConfig({
height: {
screen: "100svh",
},
keyframes: {
glow: {
"0%, 100%": { opacity: "0.5" },
"50%": { opacity: "0.7" },
},
},
},
},
plugins: [

View File

@ -85,6 +85,9 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
},
googleServicesFile: "./build/google-services.json",
},
androidStatusBar: {
translucent: true,
},
// web: {
// bundler: "metro",
// output: "static",
@ -139,6 +142,7 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
},
],
[require("./scripts/with-follow-app-delegate.js")],
[require("./scripts/with-gradle-jvm-heap-size-increase.js")],
"expo-secure-store",
"@react-native-firebase/app",
"@react-native-firebase/crashlytics",
@ -150,6 +154,12 @@ export default ({ config }: ConfigContext): ExpoConfig => ({
},
],
"react-native-video",
[
"expo-notifications",
{
enableBackgroundRemoteNotifications: true,
},
],
],
experiments: {
typedRoutes: true,

View File

@ -0,0 +1,28 @@
# What's New in v0.1.5
## Shiny new things
- Added configurable notifications for new entries (#3424)
- Introduced unread count badges (#3424)
- Integrated document links into the actions page (7e553db)
- Enabled marking feeds or categories as read (53b2136)
- Provided an option to display detailed unread counts (#3453)
## Improvements
- Enhanced AI summary styles (87aa062)
- Now displays an error message when a feed request fails (#3455)
- Refined the timeline list separator style (b1c356f)
- Concealed the share button when not applicable to a feed (#3424)
- Upgraded toast styles (83e7764)
- Set AI summary to be enabled by default (8da8a71)
- Achieved significant performance improvements (23eaf86)
## No longer broken
- Resolved issues preventing sharing of feed URLs (#3424)
- Corrected the subscription list to accurately distinguish categories with identical names across different views (d70f082)
- Eliminated duplicated menu options (f24f21a)
- Adjusted entry text positions to avoid occasional misplacement (17dd1e7)
- Prevented view selector blinking when pressed (d66d49c)
- Fixed a crash caused by loading specific images (7b20633)

View File

@ -1,7 +1,7 @@
# What's New in vNEXT_VERSION
## New Features
## Shiny new things
## Improvements
## Bug Fixes
## No longer broken

View File

@ -11,7 +11,6 @@
"TabBarModule",
"TabScreenModule",
"TabBarPortalModule",
"ProfileViewModule",
"EnhancePagerViewModule",
"EnhancePageViewModule"
]

View File

@ -22,7 +22,7 @@ Pod::Spec.new do |s|
s.dependency 'ExpoModulesCore'
s.dependency 'SnapKit', '~> 5.7.0'
s.dependency 'SDWebImage', '~> 5.0'
s.dependency "ToastViewSwift", "~> 2.1.3"
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
'DEFINES_MODULE' => 'YES',

View File

@ -8,180 +8,196 @@ import ExpoModulesCore
import UIKit
public class HelperModule: Module {
public func definition() -> ExpoModulesCore.ModuleDefinition {
Name("Helper")
public func definition() -> ExpoModulesCore.ModuleDefinition {
Name("Helper")
AsyncFunction("openLink") { (urlString: String, promise: Promise) in
guard let url = URL(string: urlString) else {
return
}
DispatchQueue.main.async {
guard let rootVC = Utils.getRootVC() else { return }
AsyncFunction("openLink") { (urlString: String, promise: Promise) in
guard let url = URL(string: urlString) else {
return
}
DispatchQueue.main.async {
guard let rootVC = Utils.getRootVC() else { return }
let onDismiss = {
promise.resolve(["type": "dismiss"])
}
let onDismiss = {
promise.resolve(["type": "dismiss"])
WebViewManager.presentModalWebView(url: url, from: rootVC, onDismiss: onDismiss)
}
}
WebViewManager.presentModalWebView(url: url, from: rootVC, onDismiss: onDismiss)
}
}
Function("scrollToTop") { (reactTag: Int) in
DispatchQueue.main.async { [weak self] in
guard let bridge = self?.appContext?.reactBridge else { return }
Function("scrollToTop") { (reactTag: Int) in
DispatchQueue.main.async { [weak self] in
guard let bridge = self?.appContext?.reactBridge else { return }
if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) {
let scrollView = self?.findUIScrollView(view: sourceView)
guard let scrollView = scrollView else {
return
}
scrollView.scrollToTopIfPossible(animated: true)
if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) {
let scrollView = self?.findUIScrollView(view: sourceView)
guard let scrollView = scrollView else {
return
}
scrollView.scrollToTopIfPossible(animated: true)
}
}
}
}
}
Function("saveImageByHandle") { (reactTag: Int) in
DispatchQueue.main.async { [weak self] in
guard let bridge = self?.appContext?.reactBridge else { return }
AsyncFunction("isScrollToEnd") { (reactTag: Int, promise: Promise) in
DispatchQueue.main.async { [weak self] in
guard let bridge = self?.appContext?.reactBridge else { return }
if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) {
let imageView = self?.findUIImageView(view: sourceView)
guard let imageView = imageView else {
return
}
guard let image = imageView.image else { return }
UIImageWriteToSavedPhotosAlbum(image, self, #selector(HelperModule.image), nil)
SPIndicator.present(title: "Saved to photos", preset: .done, haptic: .success)
if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) {
let scrollView = self?.findUIScrollView(view: sourceView)
guard let scrollView = scrollView else {
return
}
let contentHeight = scrollView.contentSize.height
let scrollViewHeight = scrollView.bounds.size.height
let contentOffsetY = scrollView.contentOffset.y
let bottomOffset = contentHeight - scrollViewHeight
promise.resolve(contentOffsetY >= bottomOffset - 1.0)
}
}
}
}
}
Function("shareImageByHandle") { (reactTag: Int, url: String?) in
DispatchQueue.main.async { [weak self] in
guard let bridge = self?.appContext?.reactBridge else { return }
if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) {
Function("saveImageByHandle") { (reactTag: Int) in
DispatchQueue.main.async { [weak self] in
guard let bridge = self?.appContext?.reactBridge else { return }
let imageView = self?.findUIImageView(view: sourceView)
guard let imageView = imageView else {
return
}
guard let image = imageView.image else { return }
let activityViewController = UIActivityViewController(
activityItems: [
image.asActivityItemSource(
url: try? URL(string: url ?? "")
)
], applicationActivities: nil)
activityViewController.popoverPresentationController?.sourceView = sourceView
activityViewController.popoverPresentationController?.sourceRect = sourceView.bounds
activityViewController.popoverPresentationController?.permittedArrowDirections = .any
activityViewController.popoverPresentationController?.permittedArrowDirections = .any
Utils.getRootVC()?.present(activityViewController, animated: true)
if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) {
let imageView = self?.findUIImageView(view: sourceView)
guard let imageView = imageView else {
return
}
guard let image = imageView.image else { return }
UIImageWriteToSavedPhotosAlbum(image, self, #selector(HelperModule.image), nil)
Toast.show(options: .init(type: .success, title: "Saved to photos"))
}
}
}
}
}
AsyncFunction("getBase64FromImageViewByHandle") { (reactTag: Int, promise: Promise) in
DispatchQueue.main.async { [weak self] in
guard let bridge = self?.appContext?.reactBridge else { return }
Function("shareImageByHandle") { (reactTag: Int, url: String?) in
DispatchQueue.main.async { [weak self] in
guard let bridge = self?.appContext?.reactBridge else { return }
if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) {
let imageView = self?.findUIImageView(view: sourceView)
guard let imageView = imageView else {
return
}
guard let image = imageView.image else { return }
let activityViewController = UIActivityViewController(
activityItems: [
image.asActivityItemSource(
url: try? URL(string: url ?? "")
),
], applicationActivities: nil)
activityViewController.popoverPresentationController?.sourceView = sourceView
activityViewController.popoverPresentationController?.sourceRect = sourceView.bounds
activityViewController.popoverPresentationController?.permittedArrowDirections = .any
activityViewController.popoverPresentationController?.permittedArrowDirections = .any
if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) {
let imageView = self?.findUIImageView(view: sourceView)
guard let imageView = imageView else {
promise.reject(
NSError(
domain: "HelperModule", code: 0,
userInfo: [NSLocalizedDescriptionKey: "Image view not found"]))
return
}
let base64 = self?.getBase64FromImageView(imageView: imageView)
promise.resolve(["base64": base64])
Utils.getRootVC()?.present(activityViewController, animated: true)
}
}
}
}
}
Function("copyImageByHandle") { (reactTag: Int) in
DispatchQueue.main.async { [weak self] in
guard let bridge = self?.appContext?.reactBridge else { return }
AsyncFunction("getBase64FromImageViewByHandle") { (reactTag: Int, promise: Promise) in
DispatchQueue.main.async { [weak self] in
guard let bridge = self?.appContext?.reactBridge else { return }
if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) {
let imageView = self?.findUIImageView(view: sourceView)
guard let imageView = imageView else {
return
}
guard let image = imageView.image else { return }
guard let imageData = image.pngData() else { return }
UIPasteboard.general.setData(imageData, forPasteboardType: "public.png")
SPIndicator.present(title: "Image copied to clipboard", preset: .done, haptic: .success)
if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) {
let imageView = self?.findUIImageView(view: sourceView)
guard let imageView = imageView else {
promise.reject(
NSError(
domain: "HelperModule", code: 0,
userInfo: [NSLocalizedDescriptionKey: "Image view not found"]))
return
}
let base64 = self?.getBase64FromImageView(imageView: imageView)
promise.resolve(["base64": base64])
}
}
}
}
}
}
func getBase64FromImageView(imageView: UIImageView) -> String? {
guard let image = imageView.image else { return nil }
guard let imageData = image.pngData() else { return nil }
Function("copyImageByHandle") { (reactTag: Int) in
DispatchQueue.main.async { [weak self] in
guard let bridge = self?.appContext?.reactBridge else { return }
let base64String = imageData.base64EncodedString(options: .lineLength64Characters)
return base64String
}
if let sourceView = bridge.uiManager.view(forReactTag: NSNumber(value: reactTag)) {
let imageView = self?.findUIImageView(view: sourceView)
guard let imageView = imageView else {
return
}
guard let image = imageView.image else { return }
guard let imageData = image.pngData() else { return }
UIPasteboard.general.setData(imageData, forPasteboardType: "public.png")
@objc func image(
_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer?
) {
if let error = error {
SPIndicator.present(title: "Save image failed", preset: .error, haptic: .error)
} else {
SPIndicator.present(title: "Save image success", preset: .done, haptic: .success)
}
}
private func findUIScrollView(view: UIView?) -> UIScrollView? {
return findUIViewOfType(view: view)
}
private func findUIImageView(view: UIView?) -> UIImageView? {
return findUIViewOfType(view: view)
}
private func findUIViewOfType<T: UIView>(view: UIView?) -> T? {
guard let view = view else {
return nil
}
if let view = view as? T {
return view
Toast.show(options: .init(type: .success, title: "Image copied to clipboard"))
}
}
}
}
let subviews = view.subviews
for subview in subviews {
if let targetView = findUIViewOfType(view: subview) as T? {
return targetView
}
func getBase64FromImageView(imageView: UIImageView) -> String? {
guard let image = imageView.image else { return nil }
guard let imageData = image.pngData() else { return nil }
let base64String = imageData.base64EncodedString(options: .lineLength64Characters)
return base64String
}
@objc func image(
_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer?
) {
if let error = error {
Toast.show(options: .init(type: .error, title: "Save image failed"))
} else {
Toast.show(options: .init(type: .success, title: "Saved to photos"))
}
}
private func findUIScrollView(view: UIView?) -> UIScrollView? {
return findUIViewOfType(view: view)
}
private func findUIImageView(view: UIView?) -> UIImageView? {
return findUIViewOfType(view: view)
}
private func findUIViewOfType<T: UIView>(view: UIView?) -> T? {
guard let view = view else {
return nil
}
if let view = view as? T {
return view
}
let subviews = view.subviews
for subview in subviews {
if let targetView = findUIViewOfType(view: subview) as T? {
return targetView
}
}
return nil
}
return nil
}
}
extension UIScrollView {
func scrollToTopIfPossible(animated: Bool) {
let encodedSelector = "X3Njcm9sbFRvVG9wSWZQb3NzaWJsZTo=" // "_scrollToTopIfPossible:"
func scrollToTopIfPossible(animated: Bool) {
let encodedSelector = "X3Njcm9sbFRvVG9wSWZQb3NzaWJsZTo=" // "_scrollToTopIfPossible:"
if let decodedData = Data(base64Encoded: encodedSelector),
let decodedString = String(data: decodedData, encoding: .utf8)
{
let selector = NSSelectorFromString(decodedString)
if self.responds(to: selector) {
self.perform(selector, with: animated)
} else {
print("UIScrollView does not respond to decoded method")
self.setContentOffset(.zero, animated: animated)
}
} else {
self.setContentOffset(.zero, animated: animated)
if let decodedData = Data(base64Encoded: encodedSelector),
let decodedString = String(data: decodedData, encoding: .utf8) {
let selector = NSSelectorFromString(decodedString)
if responds(to: selector) {
perform(selector, with: animated)
} else {
print("UIScrollView does not respond to decoded method")
setContentOffset(.zero, animated: animated)
}
} else {
setContentOffset(.zero, animated: animated)
}
}
}
}

View File

@ -1,41 +0,0 @@
//
// ProfileViewModule.swift
// Pods
//
// Created by Innei on 2025/3/24.
//
import ExpoModulesCore
import SwiftUI
public class ProfileViewModule: Module {
public func definition() -> ModuleDefinition {
Name("ProfileView")
Events("onPress")
View(ExpoProfileView.self)
}
}
public class ProfileViewProps: ExpoSwiftUI.ViewProps {
@Field var payload: String
let onPress = EventDispatcher()
}
struct ProfilePayload: Codable {
var profile: ProfileData
var user: UserData
public static func parse(jsonString: String) -> ProfilePayload? {
let data = jsonString.data(using: .utf8)!
let decoder = JSONDecoder()
return try? decoder.decode(ProfilePayload.self, from: data)
}
}
struct ExpoProfileView: ExpoSwiftUI.View {
@EnvironmentObject var props: ProfileViewProps
var body: some View {
if let payload = ProfilePayload.parse(jsonString: props.payload) {
ProfileView(profile: .constant(payload.profile), user: .constant(payload.user), onPress: props.onPress)
}
}
}

View File

@ -0,0 +1,76 @@
//
// Toast.swift
// FollowNative
//
// Created by Innei on 2025/4/9.
//
import Foundation
import ToastViewSwift
public enum Toast {
enum ToastType: String {
case error
case info
case warn
case success
}
enum Position: String {
case top
case center
case bottom
}
struct ToastOptions {
var message: String?
var type: ToastType = .info
var duration: Double = 1.5
var title: String
var position: Position?
}
static func show(options: ToastOptions) {
DispatchQueue.main.async {
let config: ToastConfiguration = .init(
direction: options.position?.position() ?? .top,
dismissBy: [.time(time: TimeInterval(options.duration))]
)
ToastViewSwift.Toast.default(image: options.type.image(), title: options.title, subtitle: options.message, config: config)
.show(haptic: options.type.haptic())
}
}
}
extension Toast.Position {
func position() -> ToastViewSwift.Toast.Direction {
switch self {
case .top:
return .top
case .center:
return .center
case .bottom:
return .bottom
}
}
}
extension Toast.ToastType {
func image() -> UIImage {
switch self {
case .error: UIImage(systemName: "xmark.circle.fill")!.withTintColor(.systemRed)
case .warn: UIImage(systemName: "exclamationmark.triangle.fill")!.withTintColor(.systemOrange)
case .info: UIImage(systemName: "info.circle.fill")!.withTintColor(.systemBlue)
case .success: UIImage(systemName: "checkmark.circle.fill")!.withTintColor(.systemGreen)
}
}
func haptic() -> UINotificationFeedbackGenerator.FeedbackType {
switch self {
case .error: .error
case .info: .success
case .warn: .warning
case .success: .success
}
}
}

View File

@ -6,79 +6,62 @@
import ExpoModulesCore
enum ToastType: String, Enumerable {
case error
case info
case warn
case success
}
extension ToastType {
func type() -> SPIndicatorIconPreset {
switch self
{
case .error: .error
case .warn: .custom(UIImage(systemName: "exclamationmark.triangle")!.withTintColor(.orange))
case .info: .custom(UIImage(systemName: "info.circle")!.withTintColor(.blue))
case .success: .done
}
}
func haptic() -> SPIndicatorHaptic {
switch self {
case .error: .error
case .info: .none
case .warn: .warning
case .success: .success
}
}
}
enum Position: String, Enumerable {
case top
case center
case bottom
}
extension Position {
func toSPPresentSide() -> SPIndicatorPresentSide {
switch self {
case .top: .top
case .center: .center
case .bottom: .bottom
}
}
}
struct ToastOptions: Record {
@Field
var message: String?
@Field
var type: ToastType = .info
@Field
var duration: Double = 1.5
@Field
var title: String
@Field
var position: Position?
}
public class ToasterModule: Module {
public func definition() -> ModuleDefinition {
Name("Toaster")
Function("toast") { (value: ToastOptions) in
DispatchQueue.main.sync {
let indicatorView = SPIndicatorView(
title: value.title, message: value.message, preset: value.type.type())
if value.position != nil {
indicatorView.presentSide = value.position!.toSPPresentSide()
}
indicatorView.present(duration: value.duration, haptic: value.type.haptic())
}
struct ToastOptions: Record {
@Field
var message: String?
@Field
var type: ToastType = .info
@Field
var duration: Double = 1.5
@Field
var title: String
@Field
var position: Position?
}
enum ToastType: String, Enumerable {
case error
case info
case warn
case success
func toToastType() -> Toast.ToastType {
switch self {
case .error:
return .error
case .info:
return .info
case .warn:
return .warn
case .success:
return .success
}
}
}
enum Position: String, Enumerable {
case top
case center
case bottom
func toPostion() -> Toast.Position {
switch self {
case .top:
return .top
case .center:
return .center
case .bottom:
return .bottom
}
}
}
public func definition() -> ModuleDefinition {
Name("Toaster")
Function("toast") { (value: ToastOptions) in
Toast.show(options: Toast.ToastOptions(message: value.message, type: value.type.toToastType(), duration: value.duration, title: value.title, position: value.position?.toPostion() ?? .top))
}
}
}
}

View File

@ -3,7 +3,6 @@ import UIKit
class ImageViewerController: UIViewController,
UIGestureRecognizerDelegate
{
var imageView: UIImageView = UIImageView(frame: .zero)
let imageLoader: ImageLoader
private var activityIndicator: UIActivityIndicatorView!
@ -24,6 +23,7 @@ class ImageViewerController: UIViewController,
}
// MARK: Layout Constraints
private var top: NSLayoutConstraint!
private var leading: NSLayoutConstraint!
private var trailing: NSLayoutConstraint!
@ -35,9 +35,9 @@ class ImageViewerController: UIViewController,
private var isAnimating: Bool = false
private var maxZoomScale: CGFloat = 1.0
private var sourceView: UIImageView? = nil
private var sourceView: UIImageView?
private var _error: Error? = nil
private var _error: Error?
init(
index: Int,
@ -45,7 +45,6 @@ class ImageViewerController: UIViewController,
imageLoader: ImageLoader,
sourceView: UIImageView? = nil
) {
self.index = index
self.imageItem = imageItem
self.imageLoader = imageLoader
@ -94,19 +93,18 @@ class ImageViewerController: UIViewController,
leading.isActive = true
trailing.isActive = true
bottom.isActive = true
}
override func viewDidLoad() {
super.viewDidLoad()
switch imageItem {
case .image(let img):
case let .image(img):
imageView.image = img
imageView.layoutIfNeeded()
case .url(let url, let placeholder):
case let .url(url, placeholder):
activityIndicator.startAnimating()
if let sourceView = self.sourceView {
if let sourceView = sourceView {
imageView.image = sourceView.image
}
@ -114,11 +112,11 @@ class ImageViewerController: UIViewController,
imageView.image = UIImage(systemName: "photo")?.withTintColor(
.gray, renderingMode: .alwaysOriginal)
imageView.frame = .init(origin: .zero, size: .init(width: 100, height: 100))
self.layout()
layout()
}
self.layout()
layout()
imageLoader.loadImage(url, placeholder: placeholder, imageView: imageView) {
[weak self] (image) in
[weak self] _ in
self?.layout()
self?.activityIndicator.stopAnimating()
} onError: { [weak self] error in
@ -126,8 +124,14 @@ class ImageViewerController: UIViewController,
.withTintColor(.red, renderingMode: .alwaysOriginal)
self?.imageView.frame = .init(origin: .zero, size: .init(width: 30, height: 30))
self?.layout()
SPIndicator.present(
title: "Image Load Error", message: error.localizedDescription, preset: .error)
Toast.show(
options: .init(
message: error.localizedDescription,
type: .error,
title: "Image Load Error"
))
self?.activityIndicator.stopAnimating()
self?._error = error
}
@ -136,17 +140,16 @@ class ImageViewerController: UIViewController,
}
addGestureRecognizers()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
self.navBar?.alpha = 1.0
navBar?.alpha = 1.0
}
override func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
self.navBar?.alpha = 0.0
navBar?.alpha = 0.0
}
override func viewWillLayoutSubviews() {
@ -160,8 +163,8 @@ class ImageViewerController: UIViewController,
}
// MARK: Add Gesture Recognizers
func addGestureRecognizers() {
func addGestureRecognizers() {
let panGesture = UIPanGestureRecognizer(
target: self, action: #selector(didPan(_:)))
panGesture.cancelsTouchesInView = false
@ -230,8 +233,7 @@ class ImageViewerController: UIViewController,
@objc
func didSingleTap(_ recognizer: UITapGestureRecognizer) {
let currentNavAlpha = self.navBar?.alpha ?? 0.0
let currentNavAlpha = navBar?.alpha ?? 0.0
UIView.animate(withDuration: 0.235) {
self.navBar?.alpha = currentNavAlpha > 0.5 ? 0.0 : 1.0
}
@ -247,11 +249,19 @@ class ImageViewerController: UIViewController,
_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer
) {
if let error = error {
SPIndicator.present(
title: "Save Error", message: error.localizedDescription, preset: .error,
haptic: .error)
Toast.show(
options: .init(
message: error.localizedDescription,
type: .error,
title: "Save Error"
))
} else {
SPIndicator.present(title: "Saved", preset: .done, haptic: .success)
Toast.show(
options: .init(
message: "Saved",
type: .success,
title: "Save Success"
))
}
}
@ -265,14 +275,12 @@ class ImageViewerController: UIViewController,
let velocity = panGesture.velocity(in: scrollView)
return abs(velocity.y) > abs(velocity.x)
}
}
// MARK: Adjusting the dimensions
extension ImageViewerController {
func updateMinMaxZoomScaleForSize(_ size: CGSize) {
let targetSize = imageView.bounds.size
if targetSize.width == 0 || targetSize.height == 0 {
return
@ -314,14 +322,13 @@ extension ImageViewerController {
trailing.constant = xOffset
view.layoutIfNeeded()
}
}
// MARK: Animation Related stuff
extension ImageViewerController {
extension ImageViewerController {
private func executeCancelAnimation() {
self.isAnimating = true
isAnimating = true
UIView.animate(
withDuration: 0.237,
animations: {
@ -335,7 +342,6 @@ extension ImageViewerController {
}
extension ImageViewerController: UIScrollViewDelegate {
func viewForZooming(in scrollView: UIScrollView) -> UIView? {
return imageView
}
@ -346,9 +352,8 @@ extension ImageViewerController: UIScrollViewDelegate {
}
extension ImageViewerController: ImageCarouselViewControllerProtocol {
public func isLoadError() -> Bool {
guard let error = _error else { return false }
guard _error != nil else { return false }
return true
}
@ -363,7 +368,13 @@ extension ImageViewerController: ImageCarouselViewControllerProtocol {
guard let image = imageView.image else { return }
UIPasteboard.general.image = image
SPIndicator.present(title: "Copied", preset: .done, haptic: .success)
Toast.show(
options: .init(
message: "Copied",
type: .success,
title: "Copy Success"
))
}
public func shareImage() {
@ -383,5 +394,4 @@ extension ImageViewerController: ImageCarouselViewControllerProtocol {
present(activityViewController, animated: true)
}
}

View File

@ -1,88 +0,0 @@
// The MIT License (MIT)
// Copyright © 2021 Ivan Vorobei (hello@ivanvorobei.io)
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import SwiftUI
@available(iOSApplicationExtension, unavailable)
extension View {
public func SPIndicator(
isPresent: Binding<Bool>,
indicatorView: SPIndicatorView,
duration: TimeInterval = 2.0,
haptic: SPIndicatorHaptic = .none
) -> some View {
if isPresent.wrappedValue {
let indicatorCompletion = indicatorView.completion
let indicatorDismiss = {
isPresent.wrappedValue = false
indicatorCompletion?()
}
indicatorView.duration = duration
indicatorView.present(haptic: haptic, completion: indicatorDismiss)
}
return self
}
public func SPIndicator(
isPresent: Binding<Bool>,
title: String = "",
message: String? = nil,
duration: TimeInterval = 2.0,
presentSide: SPIndicatorPresentSide = .top,
dismissByDrag: Bool = true,
preset: SPIndicatorIconPreset = .done,
haptic: SPIndicatorHaptic = .none,
layout: SPIndicatorLayout? = nil,
completion: (() -> Void)? = nil
) -> some View {
let indicatorView = SPIndicatorView(title: title, message: message, preset: preset)
indicatorView.presentSide = presentSide
indicatorView.dismissByDrag = dismissByDrag
indicatorView.layout = layout ?? SPIndicatorLayout(for: preset)
indicatorView.completion = completion
return SPIndicator(
isPresent: isPresent, indicatorView: indicatorView, duration: duration, haptic: haptic)
}
public func SPIndicator(
isPresent: Binding<Bool>,
title: String,
duration: TimeInterval = 2.0,
presentSide: SPIndicatorPresentSide = .top,
dismissByDrag: Bool = true,
preset: SPIndicatorIconPreset = .done,
haptic: SPIndicatorHaptic = .none,
completion: (() -> Void)? = nil
) -> some View {
let indicatorView = SPIndicatorView(title: title, preset: preset)
indicatorView.presentSide = presentSide
indicatorView.dismissByDrag = dismissByDrag
indicatorView.completion = completion
return SPIndicator(
isPresent: isPresent, indicatorView: indicatorView, duration: duration, haptic: haptic)
}
}

View File

@ -1,80 +0,0 @@
// The MIT License (MIT)
// Copyright © 2021 Ivan Vorobei (hello@ivanvorobei.io)
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import UIKit
extension UIColor {
enum Compability {
static var systemBackground: UIColor {
return UIColor.systemBackground
}
static var secondarySystemBackground: UIColor {
return UIColor.secondarySystemBackground
}
static var separator: UIColor {
return UIColor.separator
}
static var label: UIColor {
return UIColor.label
}
static var secondaryLabel: UIColor {
return UIColor.secondaryLabel
}
}
static var buttonArea: UIColor {
return UIColor { (traits) -> UIColor in
return traits.userInterfaceStyle == .dark
? UIColor(red: 61 / 255, green: 62 / 255, blue: 66 / 255, alpha: 1)
: UIColor(red: 238 / 255, green: 238 / 255, blue: 240 / 255, alpha: 1)
}
}
@available(iOSApplicationExtension, unavailable)
static var tint: UIColor {
get {
let value = UIApplication.shared.windows.first?.tintColor
guard let tint = value else { return .systemBlue }
return tint
}
set {
UIApplication.shared.windows.forEach({ $0.tintColor = newValue })
}
}
}

View File

@ -1,32 +0,0 @@
// The MIT License (MIT)
// Copyright © 2021 Ivan Vorobei (hello@ivanvorobei.io)
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import UIKit
extension UIFont {
static func preferredFont(forTextStyle style: TextStyle, weight: Weight, addPoints: CGFloat = 0) -> UIFont {
let descriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: style)
let font = UIFont.systemFont(ofSize: descriptor.pointSize + addPoints, weight: weight)
let metrics = UIFontMetrics(forTextStyle: style)
return metrics.scaledFont(for: font)
}
}

View File

@ -1,37 +0,0 @@
// The MIT License (MIT)
// Copyright © 2021 Ivan Vorobei (hello@ivanvorobei.io)
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import UIKit
extension UILabel {
func layoutDynamicHeight(width: CGFloat) {
layoutDynamicHeight(x: frame.origin.x, y: frame.origin.y, width: width)
}
func layoutDynamicHeight(x: CGFloat, y: CGFloat, width: CGFloat) {
frame = CGRect.init(x: x, y: y, width: width, height: frame.height)
sizeToFit()
if frame.width != width {
frame = .init(x: x, y: y, width: width, height: frame.height)
}
}
}

View File

@ -1,84 +0,0 @@
// The MIT License (MIT)
// Copyright © 2021 Ivan Vorobei (hello@ivanvorobei.io)
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import UIKit
public class SPIndicatorIconErrorView: UIView, SPIndicatorIconAnimatable {
public func animate() {
animateTopToBottomLine()
animateBottomToTopLine()
}
private func animateTopToBottomLine() {
let length = frame.width
let topToBottomLine = UIBezierPath()
topToBottomLine.move(to: CGPoint(x: length * 0, y: length * 0))
topToBottomLine.addLine(to: CGPoint(x: length * 1, y: length * 1))
let animatableLayer = CAShapeLayer()
animatableLayer.path = topToBottomLine.cgPath
animatableLayer.fillColor = UIColor.clear.cgColor
animatableLayer.strokeColor = tintColor?.cgColor
animatableLayer.lineWidth = 4
animatableLayer.lineCap = .round
animatableLayer.lineJoin = .round
animatableLayer.strokeEnd = 0
self.layer.addSublayer(animatableLayer)
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.duration = 0.22
animation.fromValue = 0
animation.toValue = 1
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
animatableLayer.strokeEnd = 1
animatableLayer.add(animation, forKey: "animation")
}
private func animateBottomToTopLine() {
let length = frame.width
let bottomToTopLine = UIBezierPath()
bottomToTopLine.move(to: CGPoint(x: length * 0, y: length * 1))
bottomToTopLine.addLine(to: CGPoint(x: length * 1, y: length * 0))
let animatableLayer = CAShapeLayer()
animatableLayer.path = bottomToTopLine.cgPath
animatableLayer.fillColor = UIColor.clear.cgColor
animatableLayer.strokeColor = tintColor?.cgColor
animatableLayer.lineWidth = 4
animatableLayer.lineCap = .round
animatableLayer.lineJoin = .round
animatableLayer.strokeEnd = 0
self.layer.addSublayer(animatableLayer)
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.duration = 0.22
animation.fromValue = 0
animation.toValue = 1
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
animatableLayer.strokeEnd = 1
animatableLayer.add(animation, forKey: "animation")
}
}

View File

@ -1,51 +0,0 @@
// The MIT License (MIT)
// Copyright © 2021 Ivan Vorobei (hello@ivanvorobei.io)
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import UIKit
public class SPIndicatorIconDoneView: UIView, SPIndicatorIconAnimatable {
public func animate() {
let length = frame.width
let animatablePath = UIBezierPath()
animatablePath.move(to: CGPoint(x: length * 0.196, y: length * 0.527))
animatablePath.addLine(to: CGPoint(x: length * 0.47, y: length * 0.777))
animatablePath.addLine(to: CGPoint(x: length * 0.99, y: length * 0.25))
let animatableLayer = CAShapeLayer()
animatableLayer.path = animatablePath.cgPath
animatableLayer.fillColor = UIColor.clear.cgColor
animatableLayer.strokeColor = tintColor?.cgColor
animatableLayer.lineWidth = 4
animatableLayer.lineCap = .round
animatableLayer.lineJoin = .round
animatableLayer.strokeEnd = 0
layer.addSublayer(animatableLayer)
let animation = CABasicAnimation(keyPath: "strokeEnd")
animation.duration = 0.25
animation.fromValue = 0
animation.toValue = 1
animation.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
animatableLayer.strokeEnd = 1
animatableLayer.add(animation, forKey: "animation")
}
}

View File

@ -1,43 +0,0 @@
// The MIT License (MIT)
// Copyright © 2021 Ivan Vorobei (hello@ivanvorobei.io)
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import UIKit
/**
SPIndicator: Layout model for view.
*/
open class SPIndicatorLayout {
/**
SPIndicator: Icon size.
*/
open var iconSize: CGSize
/**
SPIndicator: Alert margings for each side.
*/
open var margins: UIEdgeInsets
public init(iconSize: CGSize, margins: UIEdgeInsets) {
self.iconSize = iconSize
self.margins = margins
}
}

View File

@ -1,32 +0,0 @@
// The MIT License (MIT)
// Copyright © 2021 Ivan Vorobei (hello@ivanvorobei.io)
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import Foundation
/**
SPIndicator: Cases from preset styles.
*/
public enum SPIndicatorPresentSide {
case top
case center
case bottom
}

View File

@ -1,33 +0,0 @@
// The MIT License (MIT)
// Copyright © 2021 Ivan Vorobei (hello@ivanvorobei.io)
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import UIKit
/**
SPIndicator: Protocol for animatable views.
*/
public protocol SPIndicatorIconAnimatable {
/**
SPIndicator: Shoud call when need start animation.
*/
func animate()
}

View File

@ -1,83 +0,0 @@
// The MIT License (MIT)
// Copyright © 2021 Ivan Vorobei (hello@ivanvorobei.io)
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import UIKit
@available(iOSApplicationExtension, unavailable)
public enum SPIndicator {
/**
SPIndicator: Present alert with preset and custom haptic.
- parameter title: Title text in alert.
- parameter message: Subtitle text in alert. Optional.
- parameter preset: Icon ready-use style or custom image.
- parameter haptic: Haptic response with present. Default is `.success`.
- parameter presentSide: Choose from side appear indicator.
- parameter completion: Will call with dismiss alert.
*/
public static func present(
title: String, message: String? = nil, preset: SPIndicatorIconPreset,
haptic: SPIndicatorHaptic, from presentSide: SPIndicatorPresentSide = .top,
completion: (() -> Void)? = nil
) {
let alertView = SPIndicatorView(title: title, message: message, preset: preset)
alertView.presentSide = presentSide
alertView.present(haptic: haptic, completion: completion)
}
/**
SPIndicator: Present alert with preset and automatically detect type haptic.
- parameter title: Title text in alert.
- parameter message: Subtitle text in alert. Optional.
- parameter preset: Icon ready-use style or custom image.
- parameter presentSide: Choose from side appear indicator. Default is `.top`.
- parameter completion: Will call with dismiss alert.
*/
public static func present(
title: String, message: String? = nil, preset: SPIndicatorIconPreset,
from presentSide: SPIndicatorPresentSide = .top, completion: (() -> Void)? = nil
) {
let alertView = SPIndicatorView(title: title, message: message, preset: preset)
alertView.presentSide = presentSide
let haptic = preset.getHaptic()
alertView.present(haptic: haptic, completion: completion)
}
/**
SPIndicator: Show only message, without title and icon.
- parameter message: Title text.
- parameter haptic: Haptic response with present. Default is `.success`.
- parameter presentSide: Choose from side appear indicator. Default is `.top`.
- parameter completion: Will call with dismiss alert.
*/
public static func present(
title: String, message: String? = nil, haptic: SPIndicatorHaptic,
from presentSide: SPIndicatorPresentSide = .top, completion: (() -> Void)? = nil
) {
let alertView = SPIndicatorView(title: title, message: message)
alertView.presentSide = presentSide
alertView.present(haptic: haptic, completion: completion)
}
}

View File

@ -1,119 +0,0 @@
// The MIT License (MIT)
// Copyright © 2021 Ivan Vorobei (hello@ivanvorobei.io)
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import UIKit
/**
SPIndicator: Represent icon wrapper.
Included default styles and can be custom image.
*/
public enum SPIndicatorIconPreset {
case done
case error
case spin(_ style: UIActivityIndicatorView.Style)
case custom(_ image: UIImage)
}
// Get view and haptic by Preset.
public extension SPIndicatorIconPreset {
func createView() -> UIView {
switch self {
case .done:
let view = SPIndicatorIconDoneView()
view.tintColor = .systemBlue
return view
case .error:
let view = SPIndicatorIconErrorView()
view.tintColor = UIColor.systemRed
return view
case .spin(let style):
let view = UIActivityIndicatorView(style: style)
view.startAnimating()
return view
case .custom(let image):
let imageView = UIImageView(image: image)
imageView.contentMode = .scaleAspectFit
return imageView
}
}
func getHaptic() -> SPIndicatorHaptic {
switch self {
case .error: return .error
case .done: return .success
case .spin(_): return .none
case .custom(_): return .success
}
}
}
// Get layout by preset.
public extension SPIndicatorLayout {
convenience init() {
self.init(
iconSize: .init(
width: Self.defaultIconSideSize,
height: Self.defaultIconSideSize
),
margins: .init(
top: Self.defaultVerticallInset,
left: Self.defaultHorizontalInset,
bottom: Self.defaultVerticallInset,
right: Self.defaultHorizontalInset
)
)
}
static func message() -> SPIndicatorLayout {
let layout = SPIndicatorLayout()
return layout
}
convenience init(for preset: SPIndicatorIconPreset) {
switch preset {
case .done:
self.init()
iconSize = .init(width: 24, height: 24)
case .error:
self.init()
iconSize = .init(width: 14, height: 14)
margins.left = 19
margins.right = margins.left
case .spin(_):
self.init()
case .custom(_):
self.init()
}
}
// Default values.
private static var defaultIconSideSize: CGFloat { 28 }
private static var defaultSpaceBetweenIconAndTitle: CGFloat { 26 }
private static var defaultVerticallInset: CGFloat { 8 }
private static var defaultHorizontalInset: CGFloat { 15 }
}

View File

@ -1,654 +0,0 @@
// The MIT License (MIT)
// Copyright © 2021 Ivan Vorobei (hello@ivanvorobei.io)
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import UIKit
/// SPIndicator: Main view. Can be customisable if need.
///
/// For change duration, check method `present` and pass duration and other specific property if need customise.
///
/// Here available set window on which shoud be present.
/// If you have some windows, you shoud configure it. Check property `presentWindow`.
///
/// For disable dismiss by drag, check property `.dismissByDrag`.
///
/// Recomended call `SPIndicator` and choose style func.
@available(iOSApplicationExtension, unavailable)
open class SPIndicatorView: UIView {
// MARK: - UIAppearance
@objc dynamic open var duration: TimeInterval = 1.5
// MARK: - Properties
/**
SPIndicator: Change it for set `top` or `bottom` present side.
Shoud be change before present, instead of no effect.
*/
open var presentSide: SPIndicatorPresentSide = .top
/**
SPIndicator: By default allow drag indicator for hide.
While indicator is dragging, dismiss not work.
This behaviar can be disabled.
*/
open var dismissByDrag: Bool = true {
didSet {
setGesture()
}
}
/**
SPIndicator: Completion call after hide indicator.
*/
open var completion: (() -> Void)? = nil
// MARK: - Views
open var titleLabel: UILabel?
open var subtitleLabel: UILabel?
open var iconView: UIView?
private lazy var backgroundView: UIVisualEffectView = {
let view: UIVisualEffectView = {
if #available(iOS 13.0, *) {
return UIVisualEffectView(effect: UIBlurEffect(style: .systemThickMaterial))
} else {
return UIVisualEffectView(effect: UIBlurEffect(style: .light))
}
}()
view.isUserInteractionEnabled = false
return view
}()
weak open var presentWindow: UIWindow?
// MARK: - Init
public init(title: String, message: String? = nil, preset: SPIndicatorIconPreset) {
super.init(frame: CGRect.zero)
commonInit()
layout = SPIndicatorLayout(for: preset)
setTitle(title)
if let message = message {
setMessage(message)
}
setIcon(for: preset)
}
public init(title: String, message: String?) {
super.init(frame: CGRect.zero)
titleAreaFactor = 1.8
minimumAreaWidth = 100
commonInit()
layout = SPIndicatorLayout.message()
setTitle(title)
if let message = message {
setMessage(message)
}
}
public required init?(coder aDecoder: NSCoder) {
self.presentSide = .top
super.init(coder: aDecoder)
commonInit()
}
private func commonInit() {
preservesSuperviewLayoutMargins = false
insetsLayoutMarginsFromSafeArea = false
backgroundColor = .clear
backgroundView.layer.masksToBounds = true
addSubview(backgroundView)
setShadow()
setGesture()
}
// MARK: - Configure
private func setTitle(_ text: String) {
let label = UILabel()
label.font = UIFont.preferredFont(
forTextStyle: .footnote, weight: .semibold, addPoints: 0)
label.numberOfLines = 1
let style = NSMutableParagraphStyle()
style.lineBreakMode = .byTruncatingTail
style.lineSpacing = 3
label.attributedText = NSAttributedString(
string: text, attributes: [.paragraphStyle: style]
)
label.textAlignment = .center
label.textColor = UIColor.Compability.label
titleLabel = label
addSubview(label)
}
private func setMessage(_ text: String) {
let label = UILabel()
label.font = UIFont.preferredFont(
forTextStyle: .footnote, weight: .semibold, addPoints: 0)
label.numberOfLines = 3
let style = NSMutableParagraphStyle()
style.lineBreakMode = .byTruncatingTail
style.lineSpacing = 2
label.attributedText = NSAttributedString(
string: text, attributes: [.paragraphStyle: style]
)
label.textAlignment = .center
label.textColor = UIColor.Compability.label.withAlphaComponent(0.8)
subtitleLabel = label
addSubview(label)
}
private func setIcon(for preset: SPIndicatorIconPreset) {
let view = preset.createView()
self.iconView = view
addSubview(view)
}
private func setShadow() {
layer.shadowColor = UIColor.black.cgColor
layer.shadowOpacity = 0.22
layer.shadowOffset = .init(width: 0, height: 7)
layer.shadowRadius = 40
// Not use render shadow becouse backgorund is visual effect.
// If turn on it, background will hide.
// layer.shouldRasterize = true
}
private func setGesture() {
if dismissByDrag {
let gestureRecognizer = UIPanGestureRecognizer(
target: self, action: #selector(handlePan))
addGestureRecognizer(gestureRecognizer)
self.gestureRecognizer = gestureRecognizer
} else {
self.gestureRecognizer = nil
}
}
// MARK: - Present
private var presentAndDismissDuration: TimeInterval = 0.6
private var presentWithOpacity: Bool {
if presentSide == .center { return true }
return false
}
open func present(haptic: SPIndicatorHaptic = .success, completion: (() -> Void)? = nil) {
present(duration: self.duration, haptic: haptic, completion: completion)
}
open func present(
duration: TimeInterval, haptic: SPIndicatorHaptic = .success,
completion: (() -> Void)? = nil
) {
if self.presentWindow == nil {
self.presentWindow = UIApplication.shared.keyWindow
}
guard let window = self.presentWindow else { return }
window.addSubview(self)
// Prepare for present
self.whenGestureEndShoudHide = false
self.completion = completion
isHidden = true
sizeToFit()
layoutSubviews()
center.x = window.frame.midX
toPresentPosition(.prepare(presentSide))
self.alpha = presentWithOpacity ? 0 : 1
// Present
isHidden = false
haptic.impact()
UIView.animate(
withDuration: presentAndDismissDuration, delay: 0, usingSpringWithDamping: 1,
initialSpringVelocity: 0, options: [.beginFromCurrentState, .curveEaseOut],
animations: {
self.toPresentPosition(.visible(self.presentSide))
if self.presentWithOpacity { self.alpha = 1 }
},
completion: { finished in
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + duration) {
if self.gestureIsDragging {
self.whenGestureEndShoudHide = true
} else {
self.dismiss()
}
}
})
if let iconView = self.iconView as? SPIndicatorIconAnimatable {
DispatchQueue.main.asyncAfter(
deadline: DispatchTime.now() + presentAndDismissDuration / 3
) {
iconView.animate()
}
}
}
@objc open func dismiss() {
UIView.animate(
withDuration: presentAndDismissDuration, delay: 0, usingSpringWithDamping: 1,
initialSpringVelocity: 0, options: [.beginFromCurrentState, .curveEaseIn],
animations: {
self.toPresentPosition(.prepare(self.presentSide))
if self.presentWithOpacity { self.alpha = 0 }
},
completion: { finished in
self.removeFromSuperview()
self.completion?()
})
}
// MARK: - Internal
private var minimumYTranslationForHideByGesture: CGFloat = -10
private var maximumYTranslationByGesture: CGFloat = 60
private var gestureRecognizer: UIPanGestureRecognizer?
private var gestureIsDragging: Bool = false
private var whenGestureEndShoudHide: Bool = false
@objc func handlePan(_ gestureRecognizer: UIPanGestureRecognizer) {
if gestureRecognizer.state == .began || gestureRecognizer.state == .changed {
self.gestureIsDragging = true
let translation = gestureRecognizer.translation(in: self)
let newTranslation: CGFloat = {
switch presentSide {
case .top:
if translation.y <= 0 {
return translation.y
} else {
return min(maximumYTranslationByGesture, translation.y.squareRoot())
}
case .bottom:
if translation.y >= 0 {
return translation.y
} else {
let absolute = abs(translation.y)
return -min(maximumYTranslationByGesture, absolute.squareRoot())
}
case .center:
let absolute = abs(translation.y).squareRoot()
let newValue = translation.y < 0 ? -absolute : absolute
return min(maximumYTranslationByGesture, newValue)
}
}()
toPresentPosition(.fromVisible(newTranslation, from: (presentSide)))
}
if gestureRecognizer.state == .ended {
gestureIsDragging = false
var shoudDismissWhenEndAnimation: Bool = false
UIView.animate(
withDuration: presentAndDismissDuration, delay: 0, usingSpringWithDamping: 1,
initialSpringVelocity: 0, options: [.beginFromCurrentState, .curveEaseIn],
animations: {
if self.whenGestureEndShoudHide {
self.toPresentPosition(.prepare(self.presentSide))
shoudDismissWhenEndAnimation = true
} else {
let translation = gestureRecognizer.translation(in: self)
if translation.y < self.minimumYTranslationForHideByGesture {
self.toPresentPosition(.prepare(self.presentSide))
shoudDismissWhenEndAnimation = true
} else {
self.toPresentPosition(.visible(self.presentSide))
}
}
},
completion: { _ in
if shoudDismissWhenEndAnimation {
self.dismiss()
}
})
}
}
private func toPresentPosition(_ position: PresentPosition) {
let getPrepareTransform: ((_ side: SPIndicatorPresentSide) -> CGAffineTransform) = {
[weak self] side in
guard let self = self else { return .identity }
guard let window = UIApplication.shared.windows.first else { return .identity }
switch side {
case .top:
let topInset = window.safeAreaInsets.top
let position = -(topInset + 50)
return CGAffineTransform.identity.translatedBy(x: 0, y: position)
case .bottom:
let height = window.frame.height
let bottomInset = window.safeAreaInsets.bottom
let position = height + bottomInset + 50
return CGAffineTransform.identity.translatedBy(x: 0, y: position)
case .center:
return CGAffineTransform.identity.translatedBy(
x: 0, y: window.frame.height / 2 - self.frame.height / 2
).scaledBy(x: 0.9, y: 0.9)
}
}
let getVisibleTransform: ((_ side: SPIndicatorPresentSide) -> CGAffineTransform) = {
[weak self] side in
guard let self = self else { return .identity }
guard let window = UIApplication.shared.windows.first else { return .identity }
switch side {
case .top:
var topSafeAreaInsets = window.safeAreaInsets.top
if topSafeAreaInsets < 20 { topSafeAreaInsets = 20 }
let position = topSafeAreaInsets - 3 + self.offset
return CGAffineTransform.identity.translatedBy(x: 0, y: position)
case .bottom:
let height = window.frame.height
var bottomSafeAreaInsets = window.safeAreaInsets.top
if bottomSafeAreaInsets < 20 { bottomSafeAreaInsets = 20 }
let position =
height - bottomSafeAreaInsets - 3 - self.frame.height - self.offset
return CGAffineTransform.identity.translatedBy(x: 0, y: position)
case .center:
return CGAffineTransform.identity.translatedBy(
x: 0, y: window.frame.height / 2 - self.frame.height / 2)
}
}
switch position {
case .prepare(let presentSide):
transform = getPrepareTransform(presentSide)
case .visible(let presentSide):
transform = getVisibleTransform(presentSide)
case .fromVisible(let translation, let presentSide):
transform = getVisibleTransform(presentSide).translatedBy(x: 0, y: translation)
}
}
// MARK: - Layout
/**
SPIndicator: Wraper of layout values.
*/
open var layout: SPIndicatorLayout = .init()
/**
SPIndicator: Alert offset
*/
open var offset: CGFloat = 0
private var minimumAreaHeight: CGFloat = 50
private var minimumAreaWidth: CGFloat = 196
private var maximumAreaWidth: CGFloat = 260
private var titleAreaFactor: CGFloat = 2.5
private var spaceBetweenTitles: CGFloat = 1
private var spaceBetweenTitlesAndImage: CGFloat = 16
private var titlesCompactWidth: CGFloat {
if let iconView = self.iconView {
let space = iconView.frame.maxY + spaceBetweenTitlesAndImage
return frame.width - space * 2
} else {
return frame.width - layoutMargins.left - layoutMargins.right
}
}
private var titlesFullWidth: CGFloat {
if let iconView = self.iconView {
let space = iconView.frame.maxY + spaceBetweenTitlesAndImage
return frame.width - space - layoutMargins.right - self.spaceBetweenTitlesAndImage
} else {
return frame.width - layoutMargins.left - layoutMargins.right
}
}
open override func sizeThatFits(_ size: CGSize) -> CGSize {
var width = minimumAreaWidth
// First determine the width we'll use
titleLabel?.sizeToFit()
let titleWidth: CGFloat = titleLabel?.frame.width ?? 0
subtitleLabel?.sizeToFit()
let subtitleWidth: CGFloat = subtitleLabel?.frame.width ?? 0
width = (max(titleWidth, subtitleWidth) * titleAreaFactor).rounded()
if width < minimumAreaWidth { width = minimumAreaWidth }
if width > maximumAreaWidth { width = maximumAreaWidth }
// Now calculate heights based on this width
let availableTextWidth = width - layoutMargins.left - layoutMargins.right
var titleHeight: CGFloat = 0
if let titleLabel = titleLabel {
titleLabel.frame.size.width = availableTextWidth
titleLabel.sizeToFit()
titleHeight = titleLabel.frame.height
}
var subtitleHeight: CGFloat = 0
if let subtitleLabel = subtitleLabel {
subtitleLabel.frame.size.width = availableTextWidth
subtitleLabel.sizeToFit()
subtitleHeight = subtitleLabel.frame.height
}
// Calculate dynamic height based on content
var height: CGFloat = 0
if titleLabel != nil && subtitleLabel != nil {
height =
titleHeight + subtitleHeight + spaceBetweenTitles + layoutMargins.top
+ layoutMargins.bottom
} else if titleLabel != nil {
height = titleHeight + layoutMargins.top + layoutMargins.bottom
} else if subtitleLabel != nil {
height = subtitleHeight + layoutMargins.top + layoutMargins.bottom
}
// Ensure minimum height
if height < minimumAreaHeight {
height = minimumAreaHeight
}
return .init(width: width, height: height)
}
open override func layoutSubviews() {
super.layoutSubviews()
layoutMargins = layout.margins
layer.cornerRadius = frame.height / 2
backgroundView.frame = bounds
backgroundView.layer.cornerRadius = layer.cornerRadius
// Flags
let hasIcon = (self.iconView != nil)
let hasTitle = (self.titleLabel != nil)
let hasSubtite = (self.subtitleLabel != nil)
let fitTitleToCompact: Bool = {
guard let titleLabel = self.titleLabel else { return true }
titleLabel.numberOfLines = 1
titleLabel.sizeToFit()
return titleLabel.frame.width < titlesCompactWidth
}()
let fitSubtitleToCompact: Bool = {
guard let subtitleLabel = self.subtitleLabel else { return true }
let originalLines = subtitleLabel.numberOfLines
subtitleLabel.sizeToFit()
subtitleLabel.numberOfLines = originalLines
return subtitleLabel.frame.width < titlesCompactWidth
}()
let notFitAnyLabelToCompact: Bool = {
if !fitTitleToCompact { return true }
if !fitSubtitleToCompact { return true }
return false
}()
var layout: LayoutGrid = .iconTitleCentered
if (hasIcon && hasTitle && hasSubtite) && !notFitAnyLabelToCompact {
layout = .iconTitleMessageCentered
}
if (hasIcon && hasTitle && hasSubtite) && notFitAnyLabelToCompact {
layout = .iconTitleMessageLeading
}
if hasIcon && hasTitle && !hasSubtite {
layout = .iconTitleCentered
}
if !hasIcon && hasTitle && !hasSubtite {
layout = .title
}
if !hasIcon && hasTitle && hasSubtite {
layout = .titleMessage
}
// Actions
let layoutIcon = { [weak self] in
guard let self = self else { return }
guard let iconView = self.iconView else { return }
iconView.frame = .init(
origin: .init(x: self.layoutMargins.left, y: iconView.frame.origin.y),
size: self.layout.iconSize
)
iconView.center.y = self.bounds.midY
}
let layoutTitleCenteredCompact = { [weak self] in
guard let self = self else { return }
guard let titleLabel = self.titleLabel else { return }
titleLabel.textAlignment = .center
titleLabel.layoutDynamicHeight(width: self.titlesCompactWidth)
titleLabel.center.x = self.frame.width / 2
}
let layoutTitleCenteredFullWidth = { [weak self] in
guard let self = self else { return }
guard let titleLabel = self.titleLabel else { return }
titleLabel.textAlignment = .center
titleLabel.layoutDynamicHeight(width: self.titlesFullWidth)
titleLabel.center.x = self.frame.width / 2
}
let layoutTitleLeadingFullWidth = { [weak self] in
guard let self = self else { return }
guard let titleLabel = self.titleLabel else { return }
guard let iconView = self.iconView else { return }
let rtl = self.effectiveUserInterfaceLayoutDirection == .rightToLeft
titleLabel.textAlignment = rtl ? .right : .left
titleLabel.layoutDynamicHeight(width: self.titlesFullWidth)
titleLabel.frame.origin.x =
self.layoutMargins.left + iconView.frame.width + self.spaceBetweenTitlesAndImage
}
let layoutSubtitle = { [weak self] in
guard let self = self else { return }
guard let titleLabel = self.titleLabel else { return }
guard let subtitleLabel = self.subtitleLabel else { return }
subtitleLabel.textAlignment = titleLabel.textAlignment
subtitleLabel.layoutDynamicHeight(width: titleLabel.frame.width)
subtitleLabel.frame.origin.x = titleLabel.frame.origin.x
}
let layoutTitleSubtitleByVertical = { [weak self] in
guard let self = self else { return }
guard let titleLabel = self.titleLabel else { return }
guard let subtitleLabel = self.subtitleLabel else {
titleLabel.center.y = self.bounds.midY
return
}
let allHeight =
titleLabel.frame.height + subtitleLabel.frame.height + self.spaceBetweenTitles
titleLabel.frame.origin.y = (self.frame.height - allHeight) / 2
subtitleLabel.frame.origin.y = titleLabel.frame.maxY + self.spaceBetweenTitles
}
// Apply
switch layout {
case .iconTitleMessageCentered:
layoutIcon()
layoutTitleCenteredCompact()
layoutSubtitle()
case .iconTitleMessageLeading:
layoutIcon()
layoutTitleLeadingFullWidth()
layoutSubtitle()
case .iconTitleCentered:
layoutIcon()
titleLabel?.numberOfLines = 2
layoutTitleCenteredCompact()
case .iconTitleLeading:
layoutIcon()
titleLabel?.numberOfLines = 2
layoutTitleLeadingFullWidth()
case .title:
titleLabel?.numberOfLines = 2
layoutTitleCenteredFullWidth()
case .titleMessage:
layoutTitleCenteredFullWidth()
layoutSubtitle()
}
layoutTitleSubtitleByVertical()
}
// MARK: - Models
enum PresentPosition {
case prepare(_ from: SPIndicatorPresentSide)
case visible(_ from: SPIndicatorPresentSide)
case fromVisible(_ translation: CGFloat, from: SPIndicatorPresentSide)
}
enum LayoutGrid {
case iconTitleMessageCentered
case iconTitleMessageLeading
case iconTitleCentered
case iconTitleLeading
case title
case titleMessage
}
}

View File

@ -1,47 +0,0 @@
// The MIT License (MIT)
// Copyright © 2021 Ivan Vorobei (hello@ivanvorobei.io)
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
import UIKit
/// SPIndicator: Wrapper of haptic styles.
public enum SPIndicatorHaptic {
case success
case warning
case error
case none
func impact() {
let generator = UINotificationFeedbackGenerator()
switch self {
case .success:
generator.notificationOccurred(UINotificationFeedbackGenerator.FeedbackType.success)
case .warning:
generator.notificationOccurred(UINotificationFeedbackGenerator.FeedbackType.warning)
case .error:
generator.notificationOccurred(UINotificationFeedbackGenerator.FeedbackType.error)
case .none:
break
}
}
}

View File

@ -1,80 +0,0 @@
//
// FallbackIconView.swift
// FollowNative
//
// Created by Innei on 2025/3/24.
//
import SwiftUI
struct FallbackIcon: View {
var title: String
var size: CGFloat
var gray: Bool = false
var body: some View {
ZStack {
// Generate gradient colors based on the title
LinearGradient(
gradient: getGradientForTitle(title: title, gray: gray),
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: size * 0.2))
// Display first character or first two characters based on if it's CJK
Text(getDisplayText(from: title))
.font(.system(size: size * 0.5))
.foregroundColor(.white)
}
}
private func getDisplayText(from title: String) -> String {
guard !title.isEmpty else { return "" }
// Check if the first character is CJK (Chinese, Japanese, Korean)
let firstChar = title.first!
let isCJK = isCJKCharacter(firstChar)
if isCJK {
return String(firstChar)
} else {
return title.prefix(2).uppercased()
}
}
private func isCJKCharacter(_ character: Character) -> Bool {
// Unicode ranges for CJK characters
let cjkRanges: [ClosedRange<UInt32>] = [
0x4E00...0x9FFF, // CJK Unified Ideographs
0x3040...0x309F, // Hiragana
0x30A0...0x30FF, // Katakana
0xAC00...0xD7AF, // Hangul Syllables
]
let unicodeScalar = character.unicodeScalars.first!.value
return cjkRanges.contains { $0.contains(unicodeScalar) }
}
private func getGradientForTitle(title: String, gray: Bool) -> Gradient {
if gray {
return Gradient(colors: [Color.gray, Color.gray.opacity(0.7)])
}
// Simple hash function to generate consistent colors based on title
var hash = 0
for char in title {
hash = ((hash << 5) &- hash) &+ Int(char.asciiValue ?? 0)
}
// Generate hue values between 0 and 1
let hue = abs(Double(hash % 360) / 360.0)
return Gradient(colors: [
Color(hue: hue, saturation: 0.7, brightness: 0.8),
Color(hue: hue, saturation: 0.5, brightness: 0.6),
])
}
}

View File

@ -1,167 +0,0 @@
//
// FeedIconView.swift
//
// Created by Innei on 2025/3/24.
//
import SwiftUI
struct FeedIconView: View {
var feed: FeedIconFeedData?
var fallbackUrl: String?
var size: CGFloat = 20
var fallback: Bool = true
var siteUrl: String?
var body: some View {
if let iconSource = getFeedIconSource() {
AsyncImage(url: URL(string: iconSource)) { phase in
switch phase {
case .empty:
RoundedRectangle(cornerRadius: CGFloat(4), style: .continuous)
.fill(Color.gray.opacity(0.5))
.frame(width: size, height: size)
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: size, height: size)
.clipShape(RoundedRectangle(cornerRadius: 4))
case .failure:
let fallbackTitle = feed?.title ?? extractDomainFromUrl(siteUrl ?? fallbackUrl ?? "")
FallbackIcon(title: fallbackTitle, size: size)
@unknown default:
RoundedRectangle(cornerRadius: CGFloat(4), style: .continuous)
.fill(Color.gray.opacity(0.5))
.frame(width: size, height: size)
}
}
.frame(width: size, height: size)
} else {
let fallbackTitle = feed?.title ?? extractDomainFromUrl(siteUrl ?? fallbackUrl ?? "")
FallbackIcon(title: fallbackTitle, size: size)
}
}
private func getFeedIconSource() -> String? {
switch true {
case feed == nil && siteUrl != nil:
return getUrlIcon(url: siteUrl!, fallback: fallback).src
case feed != nil && feed?.image != nil && !(feed?.image?.isEmpty ?? true):
return feed?.image
case feed != nil && (feed?.image == nil || feed?.image?.isEmpty ?? true)
&& feed?.siteUrl != nil:
return getUrlIcon(url: feed!.siteUrl!, fallback: fallback).src
default:
return nil
}
}
private func getUrlIcon(url: String, fallback: Bool) -> (src: String, fallbackUrl: String) {
var src: String
var fallbackUrl = ""
if let urlObj = URL(string: url), let host = urlObj.host {
let pureDomain = extractDomainFromUrl(host)
fallbackUrl =
"https://avatar.vercel.sh/\(pureDomain).svg?text=\(pureDomain.prefix(2).uppercased())"
src = "https://unavatar.webp.se/\(host)?fallback=\(fallback)"
} else {
let pureDomain = extractDomainFromUrl(url)
src = "https://avatar.vercel.sh/\(pureDomain).svg?text=\(pureDomain.prefix(2).uppercased())"
}
return (src, fallbackUrl)
}
private func extractDomainFromUrl(_ url: String) -> String {
// Simple domain extraction - in a real app you'd want to use a more robust solution
let components = url.components(separatedBy: ".")
if components.count >= 2 {
return components[components.count - 2]
}
return url
}
}
// Feed data model
struct FeedIconFeedData: Identifiable {
var id: String
var title: String
var url: String
var image: String?
var siteUrl: String?
static func transform(_ feed: ProfileFeed) -> FeedIconFeedData {
FeedIconFeedData(
id: feed.id,
title: feed.title,
url: feed.url,
image: feed.image,
siteUrl: feed.siteUrl
)
}
#if DEBUG
static var mockData: FeedIconFeedData {
FeedIconFeedData(
id: "1",
title: "Example Feed",
url: "https://example.com/feed",
image: nil,
siteUrl: "https://example.com"
)
}
#endif
}
#Preview {
VStack(spacing: 20) {
// Feed with image
FeedIconView(
feed: FeedIconFeedData(
id: "1",
title: "Example Feed",
url: "https://example.com/feed",
image: "https://picsum.photos/200",
siteUrl: "https://example.com"
),
size: 40
)
// Feed without image, using site URL
FeedIconView(
feed: FeedIconFeedData(
id: "2",
title: "No Image Feed",
url: "https://noimage.com/feed",
image: nil,
siteUrl: "https://noimage.com"
),
size: 40
)
// Just a site URL
FeedIconView(
size: 40, siteUrl: "https://github.com"
)
// Fallback
FeedIconView(
feed: FeedIconFeedData(
id: "3",
title: "Fallback Example",
url: "https://invalid-url",
image: nil,
siteUrl: nil
),
size: 40
)
}
.padding()
}

View File

@ -1,180 +0,0 @@
//
// ProfileView.swift
//
// Created by Innei on 2025/3/24.
//
import ExpoModulesCore
import SwiftUI
struct ProfileView: View {
@Binding var profile: ProfileData
@Binding var user: UserData
let onPress: EventDispatcher
var hasAnyFeeds: Bool {
!profile.feeds.isEmpty || !profile.groupedFeeds.isEmpty
}
var body: some View {
List {
VStack {
VStack {
ProfileAvatar(image: user.image, name: user.name)
Text(user.name)
.font(.title)
.fontWeight(.bold)
.lineLimit(1)
if !user.handle.isEmpty {
Text("@\(user.handle)")
.foregroundColor(.gray)
}
}
.frame(maxWidth: .infinity)
.padding(.top, 30)
.padding(.bottom, 20)
.listRowInsets(EdgeInsets())
.listRowSeparator(.hidden)
}.listRowInsets(.none)
if !profile.lists.isEmpty {
Section(header: SectionHeaderText(text: "Lists")) {
ForEach($profile.lists, id: \.id) { $list in
Button {
onPress(["type": "list", "id": list.id])
} label: {
HStack {
ProfileListImage(
image: list.image, title: list.title,
customTitle: list.customTitle)
ProfileItemTitle(title: list.title, customTitle: list.customTitle)
}
}.foregroundStyle(.foreground)
}
}
}
if hasAnyFeeds {
Section(header: SectionHeaderText(text: "Feeds")) {
}
}
ForEach(profile.groupedFeeds.sorted(by: { $0.key < $1.key }), id: \.key) { key, value in
Section(
header:
Text(key).textCase(.none)
) {
ForEach(value, id: \.id) { feed in
Button {
onPress(["type": "feed", "id": feed.id])
} label: {
HStack {
FeedIconView(
feed: FeedIconFeedData.transform(feed), size: 24,
fallback: true,
siteUrl: feed.siteUrl
)
ProfileItemTitle(title: feed.title, customTitle: feed.customTitle)
}
}.foregroundStyle(.foreground)
}
}
}
if !profile.feeds.isEmpty {
Section(header: Text("Uncategorized Feeds").textCase(.none)) {
ForEach($profile.feeds, id: \.id) { $feed in
Button {
onPress(["type": "feed", "id": feed.id])
} label: {
HStack {
FeedIconView(
feed: FeedIconFeedData.transform(feed), size: 24,
fallback: true,
siteUrl: feed.siteUrl
)
ProfileItemTitle(title: feed.title, customTitle: feed.customTitle)
}
}.foregroundStyle(.foreground)
}
}
}
}.listStyle(.insetGrouped)
}
}
private struct ProfileListImage: View {
var image: String?
var title: String
var customTitle: String?
var body: some View {
if let image = image, !image.isEmpty {
Image(systemName: image)
.frame(width: 24, height: 24)
} else {
// Fallback icon with gradient background based on title
FallbackIcon(title: customTitle ?? title, size: 24)
}
}
}
private struct SectionHeaderText: View {
let text: String
var body: some View {
Text(text).font(.headline).foregroundStyle(.black).textCase(.none).padding(.top, 20)
}
}
private struct ProfileItemTitle: View {
let title: String
let customTitle: String?
var body: some View {
if let customTitle = customTitle, !customTitle.isEmpty {
Text(customTitle).lineLimit(1)
} else {
Text(title).lineLimit(1)
}
}
}
private struct ProfileAvatar: View {
let image: String?
let name: String
var body: some View {
AsyncImage(url: URL(string: image ?? "")) { phase in
switch phase {
case .empty:
ProgressView()
case .success(let image):
image
.resizable()
.scaledToFill()
case .failure:
FallbackIcon(title: name, size: 80)
@unknown default:
FallbackIcon(title: name, size: 80)
}
}
.scaledToFill()
.frame(width: 80, height: 80)
.clipShape(Circle())
}
}

Some files were not shown because too many files have changed in this diff Show More