release(desktop): Release v0.4.2
release(desktop): Release v0.4.2
This commit is contained in:
commit
bcf9dca64e
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
name: Build
|
||||
name: Build Desktop
|
||||
|
||||
on:
|
||||
push:
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
name: Build iOS IPA for development
|
||||
name: Build iOS for development
|
||||
|
||||
on:
|
||||
push:
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
name: Build iOS IPA
|
||||
name: Build iOS
|
||||
|
||||
on:
|
||||
push:
|
||||
2636
CHANGELOG.md
2636
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
# What's new in vNEXT_VERSION
|
||||
|
||||
## New Features
|
||||
## Shiny new things
|
||||
|
||||
## Improvements
|
||||
|
||||
## Bug Fixes
|
||||
## No longer broken
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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" />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export enum DayOf {
|
||||
Today,
|
||||
Today = 1,
|
||||
Yesterday,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,4 +6,6 @@ export type DailyView = Extract<FeedViewType, FeedViewType.Articles | FeedViewTy
|
|||
export interface DailyItemProps {
|
||||
view: DailyView
|
||||
day: DayOf
|
||||
|
||||
className?: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export const SettingAbout = () => {
|
|||
</span>
|
||||
)}
|
||||
<CopyButton
|
||||
variant="outline"
|
||||
value={
|
||||
rendererVersion
|
||||
? `${currentEnvironment}\n**Renderer**: ${rendererVersion}`
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
}),
|
||||
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ export async function registerWebPushNotifications() {
|
|||
await apiClient.messaging.$post({
|
||||
json: {
|
||||
token,
|
||||
channel: "desktop",
|
||||
channel: "web",
|
||||
},
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,12 @@ export default resolveConfig({
|
|||
height: {
|
||||
screen: "100svh",
|
||||
},
|
||||
keyframes: {
|
||||
glow: {
|
||||
"0%, 100%": { opacity: "0.5" },
|
||||
"50%": { opacity: "0.7" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
# What's New in vNEXT_VERSION
|
||||
|
||||
## New Features
|
||||
## Shiny new things
|
||||
|
||||
## Improvements
|
||||
|
||||
## Bug Fixes
|
||||
## No longer broken
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@
|
|||
"TabBarModule",
|
||||
"TabScreenModule",
|
||||
"TabBarPortalModule",
|
||||
"ProfileViewModule",
|
||||
"EnhancePagerViewModule",
|
||||
"EnhancePageViewModule"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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
Loading…
Reference in New Issue