diff --git a/eslint.config.mjs b/eslint.config.mjs index 0f8ae7e41f..5d713f31ae 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -21,6 +21,7 @@ export default defineConfig( "unicorn/consistent-function-scoping": "warn", "unicorn/prefer-module": "off", "@typescript-eslint/no-floating-promises": "off", + "@typescript-eslint/no-unused-expressions": 0, }, settings: { tailwindcss: { diff --git a/icons/mgc/check_circle_filled.svg b/icons/mgc/check_circle_filled.svg new file mode 100644 index 0000000000..bc09775ccf --- /dev/null +++ b/icons/mgc/check_circle_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/mgc/check_filled.svg b/icons/mgc/check_filled.svg new file mode 100644 index 0000000000..3f1ed461ba --- /dev/null +++ b/icons/mgc/check_filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/icons/mgc/copy_2_cute_re.svg b/icons/mgc/copy_2_cute_re.svg new file mode 100644 index 0000000000..81282013a2 --- /dev/null +++ b/icons/mgc/copy_2_cute_re.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/main/tipc.ts b/src/main/tipc.ts index ec84649559..70a55f56b8 100644 --- a/src/main/tipc.ts +++ b/src/main/tipc.ts @@ -17,7 +17,10 @@ export const router = { showContextMenu: t.procedure .input<{ - items: Array<{ type: "text", label: string } | { type: "separator" }> + items: Array< + | { type: "text", label: string, enabled?: boolean } + | { type: "separator" } + > }>() .action(async ({ input, context }) => { const menu = Menu.buildFromTemplate( @@ -29,6 +32,7 @@ export const router = { } return { label: item.label, + enabled: item.enabled ?? true, click() { context.sender.send("menu-click", index) }, diff --git a/src/renderer/src/atoms/user.ts b/src/renderer/src/atoms/user.ts new file mode 100644 index 0000000000..7dfbc944b5 --- /dev/null +++ b/src/renderer/src/atoms/user.ts @@ -0,0 +1,8 @@ +/* eslint-disable unicorn/no-unreadable-array-destructuring */ +import type { User } from "@auth/core/types" +import { createAtomHooks } from "@renderer/lib/jotai" +import { atom } from "jotai" + +export const [, , useUser, useSetUser, getUser, setUser] = createAtomHooks( + atom>(null), +) diff --git a/src/renderer/src/components/ui/auto-resize-height.tsx b/src/renderer/src/components/ui/auto-resize-height.tsx index 7b7d1f8a06..a58aab2bc0 100644 --- a/src/renderer/src/components/ui/auto-resize-height.tsx +++ b/src/renderer/src/components/ui/auto-resize-height.tsx @@ -32,6 +32,7 @@ export const AutoResizeHeight: React.FC = ({ const resizeObserver = new ResizeObserver((entries) => { // We only have one entry, so we can use entries[0]. const observedHeight = entries[0].contentRect.height + // add margin top setHeight(observedHeight) }) diff --git a/src/renderer/src/components/ui/button/index.tsx b/src/renderer/src/components/ui/button/index.tsx index 4f329e289c..ab11b54966 100644 --- a/src/renderer/src/components/ui/button/index.tsx +++ b/src/renderer/src/components/ui/button/index.tsx @@ -125,8 +125,7 @@ export const ActionButton = React.forwardRef< {tooltip} - {shortcut && - shortcut.split("+").map((key) => {key})} + {shortcut && shortcut.split("+").map((key) => {key})} diff --git a/src/renderer/src/components/ui/button/variants.tsx b/src/renderer/src/components/ui/button/variants.tsx index b4b453dbeb..9cd8fb420b 100644 --- a/src/renderer/src/components/ui/button/variants.tsx +++ b/src/renderer/src/components/ui/button/variants.tsx @@ -42,7 +42,7 @@ export const styledButtonVariant = cva( { variant: "plain", status: "disabled", - className: "text-theme-disabled border-theme-inactive dark:border-zinc-800", + className: "text-theme-disabled border-theme-inactive dark:border-zinc-800 hover:!bg-theme-background", }, ], variants: { diff --git a/src/renderer/src/components/ui/code-highlighter/copy-button.tsx b/src/renderer/src/components/ui/code-highlighter/copy-button.tsx new file mode 100644 index 0000000000..6cb4744704 --- /dev/null +++ b/src/renderer/src/components/ui/code-highlighter/copy-button.tsx @@ -0,0 +1,64 @@ +import { cn } from "@renderer/lib/utils" +import type { Variants } from "framer-motion" +import { AnimatePresence, m } from "framer-motion" +import { useCallback, useRef, useState } from "react" + +import { MotionButtonBase } from "../button" + +const copyIconVariants: Variants = { + initial: { + opacity: 1, + scale: 1, + }, + animate: { + opacity: 1, + scale: 1, + }, + exit: { + opacity: 0, + scale: 0, + }, +} + +export const CopyButton: Component<{ + value: string +}> = ({ value, className }) => { + const [copied, setCopied] = useState(false) + const copiedTimerRef = useRef() + const handleCopy = useCallback(() => { + navigator.clipboard.writeText(value) + setCopied(true) + + clearTimeout(copiedTimerRef.current) + copiedTimerRef.current = setTimeout(() => { + setCopied(false) + }, 2000) + }, [value]) + return ( + + + {copied ? ( + + ) : ( + + )} + + + ) +} diff --git a/src/renderer/src/components/ui/code-highlighter/index.ts b/src/renderer/src/components/ui/code-highlighter/index.ts new file mode 100644 index 0000000000..875356c8d4 --- /dev/null +++ b/src/renderer/src/components/ui/code-highlighter/index.ts @@ -0,0 +1 @@ +export * from "./copy-button" diff --git a/src/renderer/src/components/ui/tabs.tsx b/src/renderer/src/components/ui/tabs.tsx index 6a97972a87..848ea9913f 100644 --- a/src/renderer/src/components/ui/tabs.tsx +++ b/src/renderer/src/components/ui/tabs.tsx @@ -2,29 +2,26 @@ import * as TabsPrimitive from "@radix-ui/react-tabs" import { cn } from "@renderer/lib/utils" import type { VariantProps } from "class-variance-authority" import { cva } from "class-variance-authority" +import { m } from "framer-motion" import * as React from "react" const Tabs = TabsPrimitive.Root -const tabsListVariants = cva( - "", - { - variants: { - variant: { - default: "border-b", - rounded: "rounded-md bg-muted p-1", - }, - }, - defaultVariants: { - variant: "default", +const tabsListVariants = cva("", { + variants: { + variant: { + default: "border-b", + rounded: "rounded-md bg-muted p-1", }, }, -) + defaultVariants: { + variant: "default", + }, +}) export interface TabsListProps extends React.ComponentPropsWithoutRef, - VariantProps { -} + VariantProps {} const TabsList = React.forwardRef< React.ElementRef, TabsListProps @@ -41,39 +38,73 @@ const TabsList = React.forwardRef< )) TabsList.displayName = TabsPrimitive.List.displayName -const tabsTriggerVariants = cva( - "", - { - variants: { - variant: { - default: "py-1.5 border-b-2 border-transparent data-[state=active]:border-current data-[state=active]:text-theme-accent dark:data-[state=active]:text-theme-accent-500", - rounded: "py-1 rounded-sm data-[state=active]:bg-theme-accent-300 dark:data-[state=active]:bg-theme-accent-800 data-[state=active]:shadow-sm", - }, - }, - defaultVariants: { - variant: "default", +const tabsTriggerVariants = cva("", { + variants: { + variant: { + default: + "py-1.5 border-b-2 border-transparent data-[state=active]:text-theme-accent dark:data-[state=active]:text-theme-accent-500", + rounded: + "py-1 rounded-sm data-[state=active]:bg-theme-accent-300 dark:data-[state=active]:bg-theme-accent-800 data-[state=active]:shadow-sm", }, }, -) + defaultVariants: { + variant: "default", + }, +}) export interface TabsTriggerProps extends React.ComponentPropsWithoutRef, - VariantProps { -} + VariantProps {} const TabsTrigger = React.forwardRef< React.ElementRef, TabsTriggerProps ->(({ className, variant, ...props }, ref) => ( - -)) +>(({ className, variant, children, ...props }, ref) => { + const triggerRef = React.useRef(null) + React.useImperativeHandle(ref, () => triggerRef.current!, [ref]) + + const [isSelect, setIsSelect] = React.useState(false) + React.useLayoutEffect(() => { + if (!triggerRef.current) return + + const trigger = triggerRef.current as HTMLElement + + const isSelect = trigger.dataset.state === "active" + setIsSelect(isSelect) + const ob = new MutationObserver(() => { + const isSelect = trigger.dataset.state === "active" + setIsSelect(isSelect) + }) + ob.observe(trigger, { + attributes: true, + attributeFilter: ["data-state"], + }) + + return () => { + ob.disconnect() + } + }, []) + + return ( + + {children} + {isSelect && ( + + )} + + ) +}) TabsTrigger.displayName = TabsPrimitive.Trigger.displayName const TabsContent = React.forwardRef< diff --git a/src/renderer/src/hono.ts b/src/renderer/src/hono.ts index c6c6850cfd..c17401ba73 100644 --- a/src/renderer/src/hono.ts +++ b/src/renderer/src/hono.ts @@ -14,10 +14,10 @@ declare const routes: hono_hono_base.HonoBase; code: 0; + data: Record; }; outputFormat: "json"; status: 200; @@ -229,8 +229,8 @@ declare const routes: hono_hono_base.HonoBase("", { fetch: async (input, options = {}) => apiFetch(input.toString(), options), }) + +export const getFetchErrorMessage = (error: Error) => { + if (error instanceof FetchError) { + try { + const json = JSON.parse(error.response?._data) + // TODO get the biz code to show the error message, and for i18n + // const bizCode = json.code + return json.message || error.message + } catch { + return error.message + } + } + + return error.message +} diff --git a/src/renderer/src/lib/native-menu.ts b/src/renderer/src/lib/native-menu.ts index c7dd7311f9..238e02bd4c 100644 --- a/src/renderer/src/lib/native-menu.ts +++ b/src/renderer/src/lib/native-menu.ts @@ -9,9 +9,7 @@ export type NativeMenuItem = } | { type: "separator" } export const showNativeMenu = async ( - items: Array< - { type: "text", label: string, click: () => void } | { type: "separator" } - >, + items: Array>, e?: MouseEvent | React.MouseEvent, ) => { const nextItems = [ @@ -55,9 +53,9 @@ export const showNativeMenu = async ( } const unlisten = window.electron?.ipcRenderer.on("menu-click", (_, index) => { - const item = items[index] + const item = nextItems[index] if (item && item.type === "text") { - item.click() + item.click?.() } }) @@ -69,10 +67,11 @@ export const showNativeMenu = async ( }) await tipcClient?.showContextMenu({ - items: items.map((item) => { + items: nextItems.map((item) => { if (item.type === "text") { return { ...item, + enabled: item.enabled ?? item.click !== undefined, click: undefined, } } diff --git a/src/renderer/src/modules/claim/feed-claim-modal.tsx b/src/renderer/src/modules/claim/feed-claim-modal.tsx new file mode 100644 index 0000000000..6aad974af9 --- /dev/null +++ b/src/renderer/src/modules/claim/feed-claim-modal.tsx @@ -0,0 +1,127 @@ +import { AutoResizeHeight } from "@renderer/components/ui/auto-resize-height" +import { StyledButton } from "@renderer/components/ui/button" +import { CopyButton } from "@renderer/components/ui/code-highlighter" +import { LoadingCircle } from "@renderer/components/ui/loading" +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@renderer/components/ui/tabs" +import { useBizQuery } from "@renderer/hooks" +import { Queries } from "@renderer/queries" +import { useClaimFeedMutation } from "@renderer/queries/feed" +import { useFeedById } from "@renderer/store" +import type { FC } from "react" + +export const FeedClaimModalContent: FC<{ + feedId: string +}> = ({ feedId }) => { + const feed = useFeedById(feedId) + + const { + data: claimMessage, + isLoading, + error, + } = useBizQuery(Queries.feed.claimMessage({ feedId }), { + enabled: !!feed, + }) + + const { + mutateAsync: claim, + isPending, + isSuccess, + } = useClaimFeedMutation(feedId) + + if (!feed) return null + + if (isLoading) { + return ( +
+ +
+ ) + } + + if (error) { + return
Failed to load claim message
+ } + + return ( +
+

To claim this feed as your own, you need to verify ownership.

+

+ There are three ways to choose from, you can choose one of them to + verify. +

+ + + Content + Description + RSS Tag + + + +

Copy the content below and post it to your latest RSS feed.

+ + {claimMessage?.data.content || ""} +
+ +

+ Copy the following content and paste it into the + {" "} + {``} + {" "} + field of your + RSS feed. +

+ + {claimMessage?.data.description || ""} + +
+ +
+

Copy the code below and paste it into your RSS generator.

+

+ RSS generators generally have two formats to choose from. Please + copy the XML and JSON formats below as needed. +

+

+ XML Format +

+ {claimMessage?.data.xml || ""} +

+ JSON Format +

+ {claimMessage?.data.json || ""} +
+
+
+
+ +
+ claim()} + variant={isSuccess ? "plain" : "primary"} + > + {isSuccess && } + Claim + +
+
+ ) +} + +const BaseCodeBlock: FC<{ + children: string +}> = ({ children }) => ( +
+    {children}
+    
+  
+) diff --git a/src/renderer/src/modules/claim/hooks.ts b/src/renderer/src/modules/claim/hooks.ts new file mode 100644 index 0000000000..9b3331999c --- /dev/null +++ b/src/renderer/src/modules/claim/hooks.ts @@ -0,0 +1,22 @@ +import { useModalStack } from "@renderer/components/ui/modal/stacked/hooks" +import { getFeedById } from "@renderer/store" +import { createElement, useCallback } from "react" + +import { FeedClaimModalContent } from "./feed-claim-modal" + +export const useFeedClaimModal = ({ feedId }: { + feedId: string +}) => { + const { present } = useModalStack() + + return useCallback(() => { + const feed = getFeedById(feedId) + + if (!feed) return + + present({ + title: "Feed Claim", + content: () => createElement(FeedClaimModalContent, { feedId }), + }) + }, [feedId, present]) +} diff --git a/src/renderer/src/modules/claim/index.ts b/src/renderer/src/modules/claim/index.ts new file mode 100644 index 0000000000..a91df0a266 --- /dev/null +++ b/src/renderer/src/modules/claim/index.ts @@ -0,0 +1,2 @@ +export * from "./feed-claim-modal" +export * from "./hooks" diff --git a/src/renderer/src/modules/feed-column/item.tsx b/src/renderer/src/modules/feed-column/item.tsx index 7038d55c1e..3c45f0f744 100644 --- a/src/renderer/src/modules/feed-column/item.tsx +++ b/src/renderer/src/modules/feed-column/item.tsx @@ -1,4 +1,5 @@ import { getMainContainerElement } from "@renderer/atoms" +import { getUser } from "@renderer/atoms/user" import { FeedIcon } from "@renderer/components/feed-icon" import { useModalStack } from "@renderer/components/ui/modal/stacked/hooks" import { @@ -22,6 +23,7 @@ import { useMutation } from "@tanstack/react-query" import { memo, useCallback } from "react" import { toast } from "sonner" +import { useFeedClaimModal } from "../claim/hooks" import { FeedForm } from "../discover/feed-form" type FeedItemData = SubscriptionPlainModel @@ -114,6 +116,10 @@ const FeedItemImpl = ({ const feed = useFeedById(subscription.feedId) + const claimFeed = useFeedClaimModal({ + feedId: subscription.feedId, + }) + if (!feed) return null return (
{ + claimFeed() + }, + }, + feed.ownerUserId === getUser()?.id && { + type: "text", + label: "This feed is owned by you", + }, + { + type: "separator", + }, + { type: "text", label: "Open Feed in Browser", diff --git a/src/renderer/src/pages/(main)/(layer)/(subview)/profile/index.tsx b/src/renderer/src/pages/(main)/(layer)/(subview)/profile/index.tsx index 2165e0b684..a58890e9f7 100644 --- a/src/renderer/src/pages/(main)/(layer)/(subview)/profile/index.tsx +++ b/src/renderer/src/pages/(main)/(layer)/(subview)/profile/index.tsx @@ -1,3 +1,4 @@ +import { useUser } from "@renderer/atoms/user" import { Avatar, AvatarFallback, @@ -8,19 +9,18 @@ import { useSignOut } from "@renderer/hooks" import { views } from "@renderer/lib/constants" import { cn } from "@renderer/lib/utils" import { FeedList } from "@renderer/modules/feed-column/list" -import { useSession } from "@renderer/queries/auth" export function Component() { - const { session } = useSession() + const user = useUser() const signOut = useSignOut() return (
- - {session?.user?.name?.slice(0, 2)} + + {user?.name?.slice(0, 2)} -
{session?.user?.name}
+
{user?.name}
- -
+ +
+ {settingTabs.map((t) => ( + + + + ))} +
+
+ + {APP_NAME} +
- {settingTabs.map((t) => ( - - - - ))}
diff --git a/src/renderer/src/pages/settings/profile.tsx b/src/renderer/src/pages/settings/profile.tsx index 0ccc41e433..17947151e7 100644 --- a/src/renderer/src/pages/settings/profile.tsx +++ b/src/renderer/src/pages/settings/profile.tsx @@ -1,4 +1,5 @@ import { zodResolver } from "@hookform/resolvers/zod" +import { useUser } from "@renderer/atoms/user" import { StyledButton } from "@renderer/components/ui/button" import { Form, @@ -12,7 +13,6 @@ import { import { Input } from "@renderer/components/ui/input" import { apiClient } from "@renderer/lib/api-fetch" import { SettingsTitle } from "@renderer/modules/settings/title" -import { useSession } from "@renderer/queries/auth" import { useMutation } from "@tanstack/react-query" import { useForm } from "react-hook-form" import { toast } from "sonner" @@ -25,14 +25,14 @@ const formSchema = z.object({ }) export function Component() { - const { session } = useSession() + const user = useUser() const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { - handle: session?.user?.handle || "", - name: session?.user?.name || "", - avatar: session?.user?.image || "", + handle: user?.handle || "", + name: user?.name || "", + avatar: user?.image || "", }, }) diff --git a/src/renderer/src/providers/root-providers.tsx b/src/renderer/src/providers/root-providers.tsx index 54a5aca8cc..4721571e75 100644 --- a/src/renderer/src/providers/root-providers.tsx +++ b/src/renderer/src/providers/root-providers.tsx @@ -11,6 +11,7 @@ import { HelmetProvider } from "react-helmet-async" import { BizRouterProvider } from "./biz-router-provider" import { ContextMenuProvider } from "./context-menu-provider" +import { UserProvider } from "./user-provider" const loadFeatures = () => import("../framer-lazy-feature").then((res) => res.default) @@ -29,6 +30,7 @@ export const RootProviders: FC = ({ children }) => ( > + {children} diff --git a/src/renderer/src/providers/user-provider.tsx b/src/renderer/src/providers/user-provider.tsx new file mode 100644 index 0000000000..fd0de95835 --- /dev/null +++ b/src/renderer/src/providers/user-provider.tsx @@ -0,0 +1,14 @@ +import { useSetUser } from "@renderer/atoms/user" +import { useSession } from "@renderer/queries/auth" +import { useEffect } from "react" + +export const UserProvider = () => { + const { session } = useSession() + const setUser = useSetUser() + useEffect(() => { + if (!session?.user) return + setUser(session.user) + }, [session?.user, setUser]) + + return null +} diff --git a/src/renderer/src/queries/auth.ts b/src/renderer/src/queries/auth.ts index ea907e3d5e..d2be5db3b5 100644 --- a/src/renderer/src/queries/auth.ts +++ b/src/renderer/src/queries/auth.ts @@ -41,7 +41,7 @@ export const useSession = () => { /** * Fetch session data, copy and patch code from @hono/auth-js/react */ -export async function fetchData( +async function fetchData( path: string, req: any = {}, diff --git a/src/renderer/src/queries/feed.ts b/src/renderer/src/queries/feed.ts index d9da8c4dd2..92b8b4f37b 100644 --- a/src/renderer/src/queries/feed.ts +++ b/src/renderer/src/queries/feed.ts @@ -1,15 +1,13 @@ +import { getUser } from "@renderer/atoms/user" import { useBizQuery } from "@renderer/hooks" -import { apiClient } from "@renderer/lib/api-fetch" +import { apiClient, getFetchErrorMessage } from "@renderer/lib/api-fetch" import { defineQuery } from "@renderer/lib/defineQuery" +import { feedActions } from "@renderer/store" +import { useMutation } from "@tanstack/react-query" +import { toast } from "sonner" export const feed = { - byId: ({ - id, - url, - }: { - id?: string - url?: string - }) => + byId: ({ id, url }: { id?: string, url?: string }) => defineQuery( ["feed", id, url], async () => { @@ -26,18 +24,37 @@ export const feed = { rootKey: ["feed"], }, ), + claimMessage: ({ feedId }: { feedId: string }) => + defineQuery(["feed", "claimMessage", feedId], async () => + apiClient.feeds.claim.message.$get({ query: { feedId } })), } -export const useFeed = ({ - id, - url, -}: { - id?: string - url?: string -}) => - useBizQuery(feed.byId({ - id, - url, - }), { - enabled: !!id || !!url, - }) +export const useFeed = ({ id, url }: { id?: string, url?: string }) => + useBizQuery( + feed.byId({ + id, + url, + }), + { + enabled: !!id || !!url, + }, + ) + +export const useClaimFeedMutation = (feedId: string) => useMutation({ + mutationKey: ["claimFeed", feedId], + mutationFn: () => + apiClient.feeds.claim.challenge.$post({ + json: { + feedId, + }, + }), + + async onError(err) { + toast.error(await getFetchErrorMessage(err)) + }, + onSuccess() { + feedActions.patch(feedId, { + ownerUserId: getUser()?.id, + }) + }, +}) diff --git a/src/renderer/src/store/feed/store.ts b/src/renderer/src/store/feed/store.ts index 2a0f58e95c..79a40b84e9 100644 --- a/src/renderer/src/store/feed/store.ts +++ b/src/renderer/src/store/feed/store.ts @@ -18,7 +18,9 @@ export const useFeedStore = createZustandStore( set((state) => produce(state, (state) => { for (const feed of feeds) { - if (feed.id) { state.feeds[feed.id] = feed } + if (feed.id) { + state.feeds[feed.id] = feed + } } }), ) @@ -34,7 +36,19 @@ export const useFeedStore = createZustandStore( }), ) }, + + patch(feedId, patch) { + set((state) => + produce(state, (state) => { + const feed = state.feeds[feedId] + if (!feed) return + + Object.assign(feed, patch) + }), + ) + }, })) export const feedActions = getStoreActions(useFeedStore) -export const getFeedById = (feedId: string): Nullable => useFeedStore.getState().feeds[feedId] +export const getFeedById = (feedId: string): Nullable => + useFeedStore.getState().feeds[feedId] diff --git a/src/renderer/src/store/feed/types.ts b/src/renderer/src/store/feed/types.ts index 927666226d..a52c9703aa 100644 --- a/src/renderer/src/store/feed/types.ts +++ b/src/renderer/src/store/feed/types.ts @@ -10,4 +10,5 @@ export interface FeedActions { upsertMany: (feeds: FeedModel[]) => void optimisticUpdate: (feedId: FeedId, changed: Partial) => void clear: () => void + patch: (feedId: FeedId, patch: Partial) => void }