feat: display a tip button and tip users at the bottom of the entry content.

This commit is contained in:
DIYgod 2024-09-16 16:37:07 +08:00
parent 0a61fa37c3
commit 971f81d25d
No known key found for this signature in database
8 changed files with 108 additions and 15 deletions

View File

@ -46,6 +46,8 @@
"entry_content.readability_notice": "This content is provided by Readability. If you find typographical anomalies, please go to the source site to view the original content.",
"entry_content.render_error": "Render error:",
"entry_content.report_issue": "Report issue",
"entry_content.support_amount": "{{amount}} people supported this feed's creator.",
"entry_content.support_creator": "Support Creator",
"entry_content.web_app_notice": "Maybe web app doesn't support this content type. But you can download the desktop app.",
"entry_list.zero_unread": "Zero Unread",
"entry_list_header.daily_report": "Daily Report",

View File

@ -2614,23 +2614,23 @@ declare const _routes: hono_hono_base.HonoBase<Env, {
toEntryId: string | null;
powerToken: string;
comment: string | null;
fromUser: {
fromUser?: {
name: string | null;
id: string;
emailVerified: string | null;
image: string | null;
handle: string | null;
createdAt: string;
} | null;
toUser: {
} | null | undefined;
toUser?: {
name: string | null;
id: string;
emailVerified: string | null;
image: string | null;
handle: string | null;
createdAt: string;
} | null;
toFeed: {
} | null | undefined;
toFeed?: {
description: string | null;
title: string | null;
id: string;
@ -2644,7 +2644,7 @@ declare const _routes: hono_hono_base.HonoBase<Env, {
errorMessage: string | null;
errorAt: string | null;
ownerUserId: string | null;
} | null;
} | null | undefined;
}[];
};
outputFormat: "json" | "text";
@ -3383,6 +3383,12 @@ declare const _routes: hono_hono_base.HonoBase<Env, {
errorMessage: string | null;
errorAt: string | null;
ownerUserId: string | null;
tipUsers: {
name: string | null;
id: string;
image: string | null;
handle: string | null;
}[];
};
users: {
[x: string]: {

View File

@ -141,17 +141,22 @@ ProfileButton.displayName = "ProfileButton"
export function UserAvatar({
className,
avatarClassName,
hideName,
userId,
enableModal,
...props
}: {
className?: string
avatarClassName?: string
hideName?: boolean
userId?: string
enableModal?: boolean
} & LoginProps) {
const { session, status } = useSession({
enabled: !userId,
})
const presentUserProfile = usePresentUserProfileModal("drawer")
const profile = useAuthQuery(
defineQuery(["profiles", userId], async () => {
@ -175,8 +180,14 @@ export function UserAvatar({
"flex h-20 items-center justify-center gap-2 px-5 py-2 font-medium text-zinc-600 dark:text-zinc-300",
className,
)}
onClick={enableModal ? () => presentUserProfile(userId) : undefined}
>
<Avatar className="aspect-square h-full w-auto overflow-hidden rounded-full border bg-stone-300">
<Avatar
className={cn(
"aspect-square h-full w-auto overflow-hidden rounded-full border bg-stone-300",
avatarClassName,
)}
>
<AvatarImage
className="duration-200 animate-in fade-in-0"
src={(profile.data || session?.user)?.image || undefined}

View File

@ -2,7 +2,10 @@ import type { apiClient } from "@renderer/lib/api-fetch"
import type { languageSchema, users } from "src/hono"
import type { z } from "zod"
export type UserModel = Omit<Omit<typeof users.$inferSelect, "emailVerified">, "email">
export type UserModel = Omit<
Omit<Omit<typeof users.$inferSelect, "emailVerified">, "email">,
"createdAt"
>
export type ExtractBizResponse<T extends (...args: any[]) => any> = Exclude<
Awaited<ReturnType<T>>,
@ -17,8 +20,13 @@ export type ActiveList = {
export type FeedResponse = SubscriptionResponse[number]["feeds"]
export type TransactionModel = ExtractBizResponse<
typeof apiClient.wallets.transactions.$get
>["data"][number]
export type FeedModel = ExtractBizResponse<typeof apiClient.feeds.$get>["data"]["feed"] & {
owner?: Omit<UserModel, "createdAt"> | null
owner?: UserModel | null
tipUsers?: UserModel[] | null
}
export type SubscriptionResponse = Array<

View File

@ -11,11 +11,14 @@ import { useWhoami } from "@renderer/atoms/user"
import { m } from "@renderer/components/common/Motion"
import { ShadowDOM } from "@renderer/components/common/ShadowDOM"
import { AutoResizeHeight } from "@renderer/components/ui/auto-resize-height"
import { Button } from "@renderer/components/ui/button"
import { Divider } from "@renderer/components/ui/divider"
import { HTML } from "@renderer/components/ui/markdown"
import { Toc } from "@renderer/components/ui/markdown/components/Toc"
import { useInPeekModal } from "@renderer/components/ui/modal/inspire/PeekModal"
import { RootPortal } from "@renderer/components/ui/portal"
import { ScrollArea } from "@renderer/components/ui/scroll-area"
import { UserAvatar } from "@renderer/components/user-button"
import { isWebBuild, ROUTE_FEED_PENDING } from "@renderer/constants"
import { shortcuts } from "@renderer/constants/shortcuts"
import { useEntryReadabilityToggle } from "@renderer/hooks/biz/useEntryActions"
@ -44,6 +47,7 @@ import { useTranslation } from "react-i18next"
import { LoadingWithIcon } from "../../components/ui/loading"
import { EntryPlaceholderDaily } from "../ai/ai-daily/EntryPlaceholderDaily"
import { EntryTranslation } from "../entry-column/translation"
import { useTipModal } from "../wallet/hooks"
import { setEntryContentScrollToTop, setEntryTitleMeta } from "./atoms"
import { EntryPlaceholderLogo } from "./components/EntryPlaceholderLogo"
import { EntryHeader } from "./header"
@ -172,6 +176,12 @@ export const EntryContentRender: Component<{ entryId: string }> = ({ entryId, cl
return href
}, [entry?.entries.url, feed?.siteUrl])
const openTipModal = useTipModal({
userId: feed?.ownerUserId ?? undefined,
feedId: entry?.feedId,
entryId,
})
if (!entry) return null
const content = entry?.entries.content ?? data?.entries.content
@ -289,6 +299,45 @@ export const EntryContentRender: Component<{ entryId: string }> = ({ entryId, cl
)}
</div>
)}
{feed?.ownerUserId && (
<>
<Divider />
<div className="my-32 flex flex-col items-center gap-8">
<UserAvatar
className="w-40 flex-col gap-3 p-0"
avatarClassName="size-12"
userId={feed.ownerUserId}
enableModal={true}
/>
<Button className="text-base" onClick={() => openTipModal()}>
<i className="i-mgc-power-outline mr-1.5 text-lg" />
{t("entry_content.support_creator")}
</Button>
{feed.tipUsers?.length && (
<>
<div className="text-sm text-zinc-500">
{t("entry_content.support_amount", { amount: feed.tipUsers.length })}
</div>
<div className="flex w-fit max-w-80 flex-wrap gap-4">
{feed.tipUsers?.map((user) => (
<div key={user.id} className="size-8">
<UserAvatar
className="h-auto p-0"
avatarClassName="size-8"
userId={user?.id}
enableModal={true}
hideName={true}
/>
</div>
))}
</div>
</>
)}
</div>
</>
)}
</article>
</div>
</ScrollArea.ScrollArea>

View File

@ -114,7 +114,11 @@ const TipModalContent_: FC<{
{userId ? (
<>
<p className="text-sm font-medium">{t("tip_modal.feed_owner")}</p>
<UserAvatar className="h-8 justify-start bg-transparent p-0" userId={userId} />
<UserAvatar
className="h-8 justify-start bg-transparent p-0"
userId={userId}
enableModal={true}
/>
</>
) : (
<>

View File

@ -1,12 +1,13 @@
import { whoami } from "@renderer/atoms/user"
import { runTransactionInScope } from "@renderer/database"
import { apiClient } from "@renderer/lib/api-fetch"
import type { FeedModel } from "@renderer/models"
import type { FeedModel, UserModel } from "@renderer/models"
import { FeedService } from "@renderer/services"
import { produce } from "immer"
import { nanoid } from "nanoid"
import { getSubscriptionByFeedId } from "../subscription"
import { userActions } from "../user"
import { createZustandStore } from "../utils/helper"
import type { FeedQueryParams, FeedState } from "./types"
@ -32,9 +33,19 @@ class FeedActions {
feed.errorAt = null
}
if (feed.id) {
// Not all API return the owner, so merging is needed here.
if (state.feeds[feed.id]?.owner && !feed.owner) {
feed.owner = state.feeds[feed.id]?.owner
// Not all API return these fields, so merging is needed here.
const optionalFields = ["owner", "tipUsers"] as const
optionalFields.forEach((field) => {
if (state.feeds[feed.id!]?.[field] && !(field in feed)) {
;(feed as any)[field] = state.feeds[feed.id!]?.[field]
}
})
if (feed.owner) {
userActions.upsert(feed.owner as UserModel)
}
if (feed.tipUsers) {
userActions.upsert(feed.tipUsers)
}
state.feeds[feed.id] = feed

View File

@ -17,7 +17,9 @@ class UserActions {
set((state) =>
produce(state, (state) => {
for (const u of user) {
state.users[u.id] = u
if (u?.id) {
state.users[u.id] = u
}
}
}),
)