From 807ade6c5ae9e51fbcdbff331666a5a3dea3f0fd Mon Sep 17 00:00:00 2001 From: Ildar T Date: Sun, 15 Feb 2026 13:58:13 +0300 Subject: [PATCH 1/3] Added basic tipping layout and components --- apps/self-hosted/config.template.json | 30 +- apps/self-hosted/package.json | 1 + apps/self-hosted/rsbuild.config.ts | 13 +- .../src/core/configuration-loader.ts | 6 + apps/self-hosted/src/core/i18n.ts | 51 ++- .../blog/components/blog-post-footer.tsx | 13 + .../src/features/blog/layout/blog-sidebar.tsx | 15 + .../features/floating-menu/config-fields.ts | 44 +++ .../tipping/components/tip-button.tsx | 52 +++ .../components/tipping-currency-card.tsx | 36 ++ .../components/tipping-currency-cards.tsx | 33 ++ .../tipping/components/tipping-popover.tsx | 171 ++++++++++ .../components/tipping-step-amount.tsx | 41 +++ .../components/tipping-step-currency.tsx | 95 ++++++ .../tipping/hooks/use-tipping-config.ts | 23 ++ .../self-hosted/src/features/tipping/index.ts | 3 + .../self-hosted/src/features/tipping/types.ts | 9 + .../features/tipping/utils/tip-transaction.ts | 122 +++++++ apps/self-hosted/src/shim-bip39.ts | 13 + pnpm-lock.yaml | 307 +++++++++++++++++- 20 files changed, 1072 insertions(+), 6 deletions(-) create mode 100644 apps/self-hosted/src/features/tipping/components/tip-button.tsx create mode 100644 apps/self-hosted/src/features/tipping/components/tipping-currency-card.tsx create mode 100644 apps/self-hosted/src/features/tipping/components/tipping-currency-cards.tsx create mode 100644 apps/self-hosted/src/features/tipping/components/tipping-popover.tsx create mode 100644 apps/self-hosted/src/features/tipping/components/tipping-step-amount.tsx create mode 100644 apps/self-hosted/src/features/tipping/components/tipping-step-currency.tsx create mode 100644 apps/self-hosted/src/features/tipping/hooks/use-tipping-config.ts create mode 100644 apps/self-hosted/src/features/tipping/index.ts create mode 100644 apps/self-hosted/src/features/tipping/types.ts create mode 100644 apps/self-hosted/src/features/tipping/utils/tip-transaction.ts create mode 100644 apps/self-hosted/src/shim-bip39.ts diff --git a/apps/self-hosted/config.template.json b/apps/self-hosted/config.template.json index 0585e60a11..3f83320980 100644 --- a/apps/self-hosted/config.template.json +++ b/apps/self-hosted/config.template.json @@ -46,7 +46,12 @@ } }, "features": { - "postsFilters": ["blog", "posts", "comments", "replies"], + "postsFilters": [ + "blog", + "posts", + "comments", + "replies" + ], "likes": { "enabled": true }, @@ -60,9 +65,28 @@ }, "auth": { "enabled": true, - "methods": ["keychain", "hivesigner", "hiveauth"] + "methods": [ + "keychain", + "hivesigner", + "hiveauth" + ] + }, + "tipping": { + "general": { + "enabled": false, + "buttonLabel": "Tip" + }, + "post": { + "enabled": false, + "buttonLabel": "Tip" + }, + "amounts": [ + 1, + 5, + 10 + ] } } } } -} +} \ No newline at end of file diff --git a/apps/self-hosted/package.json b/apps/self-hosted/package.json index c45bf180e9..b011e3cb8d 100644 --- a/apps/self-hosted/package.json +++ b/apps/self-hosted/package.json @@ -50,6 +50,7 @@ "devDependencies": { "@biomejs/biome": "2.2.3", "@rsbuild/core": "^1.5.6", + "@rsbuild/plugin-node-polyfill": "^1.4.4", "@rsbuild/plugin-react": "^1.4.0", "@tailwindcss/postcss": "^4.1.13", "@tanstack/react-query-devtools": "^5.90.2", diff --git a/apps/self-hosted/rsbuild.config.ts b/apps/self-hosted/rsbuild.config.ts index a489052624..0847996c0b 100644 --- a/apps/self-hosted/rsbuild.config.ts +++ b/apps/self-hosted/rsbuild.config.ts @@ -1,16 +1,25 @@ import path from 'node:path'; +import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; import { defineConfig } from '@rsbuild/core'; +import { pluginNodePolyfill } from '@rsbuild/plugin-node-polyfill'; import { pluginReact } from '@rsbuild/plugin-react'; import { tanstackRouter } from '@tanstack/router-plugin/rspack'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const require = createRequire(import.meta.url); +const bip39Resolved = require.resolve('bip39', { + paths: [__dirname, path.join(__dirname, 'node_modules/@ecency/wallets')], +}); export default defineConfig({ - plugins: [pluginReact()], + plugins: [pluginReact(), pluginNodePolyfill()], resolve: { alias: { '@': './src', + // bip39 has only named exports; wallets uses default import — shim provides default + 'bip39': path.resolve(__dirname, 'src/shim-bip39.ts'), + 'bip39-original': bip39Resolved, }, }, tools: { @@ -22,6 +31,8 @@ export default defineConfig({ react: path.resolve(__dirname, 'node_modules/react'), 'react-dom': path.resolve(__dirname, 'node_modules/react-dom'), '@ecency/ui': path.resolve(__dirname, '../../packages/ui/dist/index.js'), + 'bip39': path.resolve(__dirname, 'src/shim-bip39.ts'), + 'bip39-original': bip39Resolved, }, }, plugins: [ diff --git a/apps/self-hosted/src/core/configuration-loader.ts b/apps/self-hosted/src/core/configuration-loader.ts index e5edb7f55a..b16072c58b 100644 --- a/apps/self-hosted/src/core/configuration-loader.ts +++ b/apps/self-hosted/src/core/configuration-loader.ts @@ -68,6 +68,12 @@ export interface InstanceConfig { post: { text2Speech: { enabled: boolean }; }; + tipping?: { + enabled?: boolean; + general?: { enabled: boolean; buttonLabel?: string }; + post?: { enabled: boolean; buttonLabel?: string }; + amounts?: number[]; + }; auth: { enabled: boolean; methods: string[]; diff --git a/apps/self-hosted/src/core/i18n.ts b/apps/self-hosted/src/core/i18n.ts index eb9091e3e9..1a95638dcf 100644 --- a/apps/self-hosted/src/core/i18n.ts +++ b/apps/self-hosted/src/core/i18n.ts @@ -59,7 +59,14 @@ type TranslationKey = | 'already_reblogged' | 'reblog_to_followers' | 'error_loading' - | 'retry'; + | 'retry' + | 'tip_amount' + | 'tip_custom' + | 'tip_currency' + | 'tip_private_key' + | 'tip_send' + | 'tip_sending' + | 'cancel'; type Translations = Record; @@ -123,6 +130,13 @@ const translations: Record = { reblog_to_followers: 'Reblog to your followers', error_loading: 'Something went wrong. Please try again.', retry: 'Retry', + tip_amount: 'Amount', + tip_custom: 'Custom', + tip_currency: 'Currency', + tip_private_key: 'Active key', + tip_send: 'Tip', + tip_sending: 'Sending...', + cancel: 'Cancel', }, es: { loading: 'Cargando...', @@ -183,6 +197,13 @@ const translations: Record = { reblog_to_followers: 'Rebloguear a tus seguidores', error_loading: 'Algo salió mal. Por favor, intente de nuevo.', retry: 'Reintentar', + tip_amount: 'Monto', + tip_custom: 'Personalizado', + tip_currency: 'Moneda', + tip_private_key: 'Clave activa', + tip_send: 'Propina', + tip_sending: 'Enviando...', + cancel: 'Cancelar', }, de: { loading: 'Lädt...', @@ -243,6 +264,13 @@ const translations: Record = { reblog_to_followers: 'An Ihre Follower rebloggen', error_loading: 'Etwas ist schief gelaufen. Bitte versuchen Sie es erneut.', retry: 'Erneut versuchen', + tip_amount: 'Betrag', + tip_custom: 'Benutzerdefiniert', + tip_currency: 'Währung', + tip_private_key: 'Aktiver Schlüssel', + tip_send: 'Trinkgeld', + tip_sending: 'Wird gesendet...', + cancel: 'Abbrechen', }, fr: { loading: 'Chargement...', @@ -303,6 +331,13 @@ const translations: Record = { reblog_to_followers: 'Repartager à vos abonnés', error_loading: "Une erreur s'est produite. Veuillez réessayer.", retry: 'Réessayer', + tip_amount: 'Montant', + tip_custom: 'Personnalisé', + tip_currency: 'Devise', + tip_private_key: 'Clé active', + tip_send: 'Pourboire', + tip_sending: 'Envoi...', + cancel: 'Annuler', }, ko: { loading: '로딩 중...', @@ -363,6 +398,13 @@ const translations: Record = { reblog_to_followers: '팔로워에게 리블로그', error_loading: '문제가 발생했습니다. 다시 시도해주세요.', retry: '다시 시도', + tip_amount: '금액', + tip_custom: '사용자 지정', + tip_currency: '통화', + tip_private_key: '액티브 키', + tip_send: '팁', + tip_sending: '전송 중...', + cancel: '취소', }, ru: { loading: 'Загрузка...', @@ -423,6 +465,13 @@ const translations: Record = { reblog_to_followers: 'Сделать реблог для подписчиков', error_loading: 'Что-то пошло не так. Пожалуйста, попробуйте снова.', retry: 'Повторить', + tip_amount: 'Сумма', + tip_custom: 'Своя', + tip_currency: 'Валюта', + tip_private_key: 'Активный ключ', + tip_send: 'Отправить', + tip_sending: 'Отправка...', + cancel: 'Отмена', }, }; diff --git a/apps/self-hosted/src/features/blog/components/blog-post-footer.tsx b/apps/self-hosted/src/features/blog/components/blog-post-footer.tsx index fccb7851d3..73a45f5a53 100644 --- a/apps/self-hosted/src/features/blog/components/blog-post-footer.tsx +++ b/apps/self-hosted/src/features/blog/components/blog-post-footer.tsx @@ -5,6 +5,7 @@ import { UilComment } from '@tooni/iconscout-unicons-react'; import { useMemo } from 'react'; import { InstanceConfigManager, t } from '@/core'; import { VoteButton, ReblogButton } from '@/features/auth'; +import { TipButton } from '@/features/tipping'; interface Props { entry: Entry; @@ -21,6 +22,10 @@ export function BlogPostFooter({ entry }: Props) { ({ configuration }) => configuration.instanceConfiguration.features.comments?.enabled ?? true, ); + const showTippingPost = InstanceConfigManager.getConfigValue( + ({ configuration }) => + configuration.instanceConfiguration.features.tipping?.post?.enabled ?? false, + ); const commentsCount = entryData.children || 0; const reblogsCount = entryData.reblogs || 0; @@ -67,6 +72,14 @@ export function BlogPostFooter({ entry }: Props) { permlink={entryData.permlink} reblogCount={reblogsCount} /> + {showTippingPost && ( + + )} ); diff --git a/apps/self-hosted/src/features/blog/layout/blog-sidebar.tsx b/apps/self-hosted/src/features/blog/layout/blog-sidebar.tsx index 7b2b4a84eb..84bd2f3ebb 100644 --- a/apps/self-hosted/src/features/blog/layout/blog-sidebar.tsx +++ b/apps/self-hosted/src/features/blog/layout/blog-sidebar.tsx @@ -1,5 +1,6 @@ import { formatMonthYear, InstanceConfigManager, t } from "@/core"; import { UserAvatar } from "@/features/shared/user-avatar"; +import { TipButton } from "@/features/tipping"; import { getAccountFullQueryOptions } from "@ecency/sdk"; import { useQuery } from "@tanstack/react-query"; import { useMemo } from "react"; @@ -24,6 +25,11 @@ function BlogSidebarContent({ username }: { username: string }) { enabled: !!username, }); + const showTippingGeneral = InstanceConfigManager.getConfigValue( + ({ configuration }) => + configuration.instanceConfiguration.features.tipping?.general?.enabled ?? false + ); + const joinDate = useMemo(() => { if (!data?.created) return null; return formatMonthYear(data.created); @@ -82,6 +88,15 @@ function BlogSidebarContent({ username }: { username: string }) { )} )} + {showTippingGeneral && ( +
+ +
+ )} {data?.profile?.location && (
{t("location")}:{" "} diff --git a/apps/self-hosted/src/features/floating-menu/config-fields.ts b/apps/self-hosted/src/features/floating-menu/config-fields.ts index 3060d093b1..72172755da 100644 --- a/apps/self-hosted/src/features/floating-menu/config-fields.ts +++ b/apps/self-hosted/src/features/floating-menu/config-fields.ts @@ -200,6 +200,50 @@ export const configFieldsMap: Record = { }, }, }, + tipping: { + label: 'Tipping', + type: 'section', + description: 'Tip button in posts and sidebar', + fields: { + general: { + label: 'Sidebar (General)', + type: 'section', + fields: { + enabled: { + label: 'Enabled', + type: 'boolean', + description: 'Show Tip button in sidebar', + }, + buttonLabel: { + label: 'Button Label', + type: 'string', + description: 'Custom label for Tip button (e.g. Tip)', + }, + }, + }, + post: { + label: 'Post', + type: 'section', + fields: { + enabled: { + label: 'Enabled', + type: 'boolean', + description: 'Show Tip button in post footer', + }, + buttonLabel: { + label: 'Button Label', + type: 'string', + description: 'Custom label for Tip button (e.g. Tip)', + }, + }, + }, + amounts: { + label: 'Preset Amounts', + type: 'array', + description: 'Preset amounts in USD for tip buttons (e.g. 1, 5, 10)', + }, + }, + }, auth: { label: 'Authentication', type: 'section', diff --git a/apps/self-hosted/src/features/tipping/components/tip-button.tsx b/apps/self-hosted/src/features/tipping/components/tip-button.tsx new file mode 100644 index 0000000000..3ab40e3410 --- /dev/null +++ b/apps/self-hosted/src/features/tipping/components/tip-button.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { useRef, useState } from 'react'; +import { UilDollarSign } from '@tooni/iconscout-unicons-react'; +import { useTippingConfig } from '../hooks/use-tipping-config'; +import { TippingPopover } from './tipping-popover'; +import type { TippingVariant } from '../types'; + +interface TipButtonProps { + recipientUsername: string; + variant: TippingVariant; + memo?: string; + className?: string; +} + +export function TipButton({ + recipientUsername, + variant, + memo = '', + className, +}: TipButtonProps) { + const { enabled, buttonLabel, presetAmounts } = useTippingConfig(variant); + const [open, setOpen] = useState(false); + const anchorRef = useRef(undefined); + + if (!enabled) return undefined; + + return ( + <> + + {open && ( + setOpen(false)} + anchorRef={anchorRef} + /> + )} + + ); +} diff --git a/apps/self-hosted/src/features/tipping/components/tipping-currency-card.tsx b/apps/self-hosted/src/features/tipping/components/tipping-currency-card.tsx new file mode 100644 index 0000000000..730cd346d4 --- /dev/null +++ b/apps/self-hosted/src/features/tipping/components/tipping-currency-card.tsx @@ -0,0 +1,36 @@ +import { useInstanceConfig } from "@/features/blog/hooks/use-instance-config"; +import { getAccountWalletAssetInfoQueryOptions } from "@ecency/wallets"; +import { useQuery } from "@tanstack/react-query"; +import clsx from "clsx"; + +interface Props { + asset: string; + selectedAsset: string | undefined; + onAssetSelect: (value: string) => void; +} + +export function TippingCurrencyCard({ + asset, + selectedAsset, + onAssetSelect, +}: Props) { + const { username } = useInstanceConfig(); + const { data } = useQuery( + getAccountWalletAssetInfoQueryOptions(username, asset), + ); + return ( + + ); +} diff --git a/apps/self-hosted/src/features/tipping/components/tipping-currency-cards.tsx b/apps/self-hosted/src/features/tipping/components/tipping-currency-cards.tsx new file mode 100644 index 0000000000..b075f333ff --- /dev/null +++ b/apps/self-hosted/src/features/tipping/components/tipping-currency-cards.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { useInstanceConfig } from "@/features/blog/hooks/use-instance-config"; +import { getAccountWalletListQueryOptions } from "@ecency/wallets"; +import { useQuery } from "@tanstack/react-query"; +import { TippingCurrencyCard } from "./tipping-currency-card"; + +interface TippingCurrencyCardsProps { + selectedAsset: string | undefined; + onAssetSelect: (asset: string) => void; +} + +export function TippingCurrencyCards({ + selectedAsset, + onAssetSelect, +}: TippingCurrencyCardsProps) { + const { username } = useInstanceConfig(); + const { data: walletList } = useQuery( + getAccountWalletListQueryOptions(username, "usd"), + ); + + return ( +
+ {walletList?.map((asset) => ( + onAssetSelect(asset)} + /> + ))} +
+ ); +} diff --git a/apps/self-hosted/src/features/tipping/components/tipping-popover.tsx b/apps/self-hosted/src/features/tipping/components/tipping-popover.tsx new file mode 100644 index 0000000000..1de745e4b1 --- /dev/null +++ b/apps/self-hosted/src/features/tipping/components/tipping-popover.tsx @@ -0,0 +1,171 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { useAuth } from "@/features/auth/hooks"; +import { + executeTip, + resolveFromAccountFromKey, + resolvePrivateKey, +} from "../utils/tip-transaction"; +import { TippingStepAmount } from "./tipping-step-amount"; +import { TippingStepCurrency } from "./tipping-step-currency"; +import type { TippingAsset } from "../types"; + +interface TippingPopoverProps { + to: string; + memo: string; + presetAmounts: number[]; + onClose: () => void; + anchorRef: React.RefObject; +} + +type Step = "amount" | "currency"; + +export function TippingPopover({ + to, + memo, + presetAmounts, + onClose, + anchorRef, +}: TippingPopoverProps) { + const { user } = useAuth(); + const panelRef = useRef(null); + const [step, setStep] = useState("amount"); + const [selectedPreset, setSelectedPreset] = useState< + number | "custom" | undefined + >(undefined); + const [amount, setAmount] = useState(""); + const [selectedAsset, setSelectedAsset] = useState( + undefined, + ); + const [privateKeyStr, setPrivateKeyStr] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(undefined); + + const amountNum = parseFloat(amount); + const hasValidAmount = Number.isFinite(amountNum) && amountNum > 0; + const hasKeyInput = privateKeyStr.trim().length > 0; + const canSubmit = + hasValidAmount && selectedAsset !== undefined && hasKeyInput && !loading; + + const handleClickOutside = useCallback( + (e: MouseEvent) => { + const anchor = anchorRef.current; + const panel = panelRef.current; + if ( + panel?.contains(e.target as Node) || + anchor?.contains(e.target as Node) + ) { + return; + } + onClose(); + }, + [anchorRef, onClose], + ); + + useEffect(() => { + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, [handleClickOutside]); + + const handlePreset = (value: number | "custom") => { + setSelectedPreset(value); + setAmount(value === "custom" ? "" : String(value)); + setStep("currency"); + setError(undefined); + }; + + const handleSubmit = useCallback(async () => { + if (!canSubmit) return; + if ( + selectedAsset !== "HIVE" && + selectedAsset !== "HBD" && + selectedAsset !== "POINTS" + ) { + setError("This asset is not supported for tipping yet"); + return; + } + setLoading(true); + setError(undefined); + try { + const key = await resolvePrivateKey(privateKeyStr.trim(), user?.username); + let from: string; + if (user?.username) { + from = user.username; + } else { + from = await resolveFromAccountFromKey(key); + } + await executeTip({ + from, + to, + amount, + asset: selectedAsset as TippingAsset, + key, + memo, + }); + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : "Transaction failed"); + } finally { + setLoading(false); + } + }, [ + canSubmit, + privateKeyStr, + user?.username, + to, + amount, + selectedAsset, + memo, + onClose, + ]); + + const anchor = anchorRef.current; + const rect = anchor?.getBoundingClientRect(); + const style = rect + ? { + position: "fixed" as const, + top: rect.bottom + 8, + left: rect.left, + zIndex: 9999, + } + : undefined; + + const content = ( +
+ {step === "amount" && ( + + )} + {step === "currency" && ( + { + setPrivateKeyStr(v); + setError(undefined); + }} + error={error} + canSubmit={canSubmit} + loading={loading} + onCancel={onClose} + onSubmit={handleSubmit} + /> + )} +
+ ); + + return createPortal(content, document.body); +} diff --git a/apps/self-hosted/src/features/tipping/components/tipping-step-amount.tsx b/apps/self-hosted/src/features/tipping/components/tipping-step-amount.tsx new file mode 100644 index 0000000000..0b0cd078ce --- /dev/null +++ b/apps/self-hosted/src/features/tipping/components/tipping-step-amount.tsx @@ -0,0 +1,41 @@ +import { t } from "@/core"; + +interface TippingStepAmountProps { + presetAmounts: number[]; + onSelect: (value: number | "custom") => void; +} + +const buttonClassName = + "px-3 py-2 rounded-md border border-theme bg-theme-primary text-theme-primary hover:bg-theme-tertiary text-sm"; + +export function TippingStepAmount({ + presetAmounts, + onSelect, +}: TippingStepAmountProps) { + return ( + <> +
+ {t("tip_amount")} +
+
+ {presetAmounts.map((val) => ( + + ))} + +
+ + ); +} diff --git a/apps/self-hosted/src/features/tipping/components/tipping-step-currency.tsx b/apps/self-hosted/src/features/tipping/components/tipping-step-currency.tsx new file mode 100644 index 0000000000..1826219a64 --- /dev/null +++ b/apps/self-hosted/src/features/tipping/components/tipping-step-currency.tsx @@ -0,0 +1,95 @@ +import { t } from "@/core"; +import { TippingCurrencyCards } from "./tipping-currency-cards"; + +interface TippingStepCurrencyProps { + amount: string; + onAmountChange: (value: string) => void; + selectedAsset: string | undefined; + onAssetSelect: (asset: string) => void; + privateKeyStr: string; + onPrivateKeyChange: (value: string) => void; + error: string | undefined; + canSubmit: boolean; + loading: boolean; + onCancel: () => void; + onSubmit: () => void; +} + +const inputClassName = + "w-full mb-3 px-3 py-2 rounded-md border border-theme bg-theme-primary text-theme-primary text-sm"; +const inputMonoClassName = `${inputClassName} font-mono`; + +export function TippingStepCurrency({ + amount, + onAmountChange, + selectedAsset, + onAssetSelect, + privateKeyStr, + onPrivateKeyChange, + error, + canSubmit, + loading, + onCancel, + onSubmit, +}: TippingStepCurrencyProps) { + return ( + <> +
+ {t("tip_amount")} +
+ onAmountChange(e.target.value)} + className={inputClassName} + /> +
+ {t("tip_currency")} +
+ + {selectedAsset !== undefined && ( + <> +
+ {t("tip_private_key")} +
+ onPrivateKeyChange(e.target.value)} + className={inputMonoClassName} + /> + + )} + {error && ( +
+ {error} +
+ )} +
+ + +
+ + ); +} diff --git a/apps/self-hosted/src/features/tipping/hooks/use-tipping-config.ts b/apps/self-hosted/src/features/tipping/hooks/use-tipping-config.ts new file mode 100644 index 0000000000..45e928f18b --- /dev/null +++ b/apps/self-hosted/src/features/tipping/hooks/use-tipping-config.ts @@ -0,0 +1,23 @@ +import { InstanceConfigManager } from '@/core'; +import { useMemo } from 'react'; +import type { TippingVariant } from '../types'; + +const DEFAULT_PRESETS = [1, 5, 10]; +const DEFAULT_BUTTON_LABEL = 'Tip'; + +export function useTippingConfig(variant: TippingVariant) { + return useMemo(() => { + const config = InstanceConfigManager.getConfig(); + const tipping = config.configuration.instanceConfiguration.features?.tipping; + if (!tipping) { + return { enabled: false, buttonLabel: DEFAULT_BUTTON_LABEL, presetAmounts: DEFAULT_PRESETS }; + } + const sub = variant === 'post' ? tipping.post : tipping.general; + const enabled = Boolean(sub?.enabled); + const buttonLabel = sub?.buttonLabel ?? DEFAULT_BUTTON_LABEL; + const presetAmounts = Array.isArray(tipping.amounts) && tipping.amounts.length > 0 + ? tipping.amounts + : DEFAULT_PRESETS; + return { enabled, buttonLabel, presetAmounts }; + }, [variant]); +} diff --git a/apps/self-hosted/src/features/tipping/index.ts b/apps/self-hosted/src/features/tipping/index.ts new file mode 100644 index 0000000000..472380550d --- /dev/null +++ b/apps/self-hosted/src/features/tipping/index.ts @@ -0,0 +1,3 @@ +export { TipButton } from './components/tip-button'; +export { useTippingConfig } from './hooks/use-tipping-config'; +export type { TippingVariant, TippingAsset, TippingConfig } from './types'; diff --git a/apps/self-hosted/src/features/tipping/types.ts b/apps/self-hosted/src/features/tipping/types.ts new file mode 100644 index 0000000000..e27b97bc23 --- /dev/null +++ b/apps/self-hosted/src/features/tipping/types.ts @@ -0,0 +1,9 @@ +export type TippingVariant = 'post' | 'general'; + +export type TippingAsset = 'HIVE' | 'HBD' | 'POINTS'; + +export interface TippingConfig { + enabled: boolean; + buttonLabel: string; + presetAmounts: number[]; +} diff --git a/apps/self-hosted/src/features/tipping/utils/tip-transaction.ts b/apps/self-hosted/src/features/tipping/utils/tip-transaction.ts new file mode 100644 index 0000000000..fab05e6453 --- /dev/null +++ b/apps/self-hosted/src/features/tipping/utils/tip-transaction.ts @@ -0,0 +1,122 @@ +import { CONFIG } from '@ecency/sdk'; +import { cryptoUtils, PrivateKey, type Operation } from '@hiveio/dhive'; +import type { TippingAsset } from '../types'; + +const KEY_TYPE = 'active' as const; + +function formatKeyError(err: unknown): string { + const msg = err instanceof Error ? err.message : String(err); + return /base58|invalid|wif/i.test(msg) + ? 'Invalid key format' + : 'Invalid private key or password'; +} + +/** + * Resolve raw key input to a PrivateKey. Same flow as apps/web KeyInput: + * - WIF (cryptoUtils.isWif) -> PrivateKey.fromString + * - With username: try master password (PrivateKey.fromLogin), then WIF + * - Without username: WIF only + * BIP44 seed support would require @ecency/wallets (detectHiveKeyDerivation, deriveHiveKeys). + */ +export async function resolvePrivateKey( + keyStr: string, + username?: string +): Promise { + const key = keyStr.trim(); + if (!key) { + throw new Error('Key is required'); + } + + try { + if (cryptoUtils.isWif(key)) { + return PrivateKey.fromString(key); + } + + if (username) { + try { + return PrivateKey.fromLogin(username, key, KEY_TYPE); + } catch { + return PrivateKey.fromString(key); + } + } + + return PrivateKey.fromString(key); + } catch (err) { + throw new Error(formatKeyError(err)); + } +} + +export interface ExecuteTipParams { + from: string; + to: string; + amount: string; + asset: TippingAsset; + key: PrivateKey; + memo: string; +} + +/** + * Resolve account name (from) from a private key using get_key_references. + */ +export async function resolveFromAccountFromKey(key: PrivateKey): Promise { + const publicKey = key.createPublic().toString(); + const result = (await CONFIG.hiveClient.database.call('get_key_references', [ + [publicKey], + ])) as string[][]; + const accounts = result?.[0]; + if (!accounts?.length) { + throw new Error('No account found for this key'); + } + return accounts[0]; +} + +/** + * Execute tip: transfer HIVE, HBD, or POINTS using CONFIG.hiveClient (no @ecency/wallets). + */ +export async function executeTip(params: ExecuteTipParams): Promise { + const { from, to, amount, asset, key, memo } = params; + const num = parseFloat(amount); + if (!Number.isFinite(num) || num <= 0) { + throw new Error('Invalid amount'); + } + const formatted = num.toFixed(3); + if (asset === 'HIVE') { + await CONFIG.hiveClient.broadcast.transfer( + { + from, + to, + amount: `${formatted} HIVE`, + memo, + }, + key + ); + } else if (asset === 'HBD') { + await CONFIG.hiveClient.broadcast.transfer( + { + from, + to, + amount: `${formatted} HBD`, + memo, + }, + key + ); + } else if (asset === 'POINTS') { + const op: Operation = [ + 'custom_json', + { + id: 'ecency_point_transfer', + json: JSON.stringify({ + sender: from, + receiver: to, + amount: `${formatted} POINT`, + memo, + }), + required_auths: [from], + required_posting_auths: [], + }, + ]; + await CONFIG.hiveClient.broadcast.sendOperations([op], key); + } else { + throw new Error(`Unsupported asset: ${asset}`); + } +} diff --git a/apps/self-hosted/src/shim-bip39.ts b/apps/self-hosted/src/shim-bip39.ts new file mode 100644 index 0000000000..e1a8491d98 --- /dev/null +++ b/apps/self-hosted/src/shim-bip39.ts @@ -0,0 +1,13 @@ +/** + * ESM shim for bip39: the package has only named exports, but @ecency/wallets + * uses both default and named imports. Re-export namespace as default and re-export names. + */ +import * as bip39 from 'bip39-original'; + +export default bip39; +export const generateMnemonic = bip39.generateMnemonic; +export const mnemonicToSeedSync = bip39.mnemonicToSeedSync; +export const mnemonicToSeed = bip39.mnemonicToSeed; +export const entropyToMnemonic = bip39.entropyToMnemonic; +export const mnemonicToEntropy = bip39.mnemonicToEntropy; +export const validateMnemonic = bip39.validateMnemonic; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab0c9c4864..d02fb8b611 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -155,6 +155,9 @@ importers: '@rsbuild/core': specifier: ^1.5.6 version: 1.7.2 + '@rsbuild/plugin-node-polyfill': + specifier: ^1.4.4 + version: 1.4.4(@rsbuild/core@1.7.2) '@rsbuild/plugin-react': specifier: ^1.4.0 version: 1.4.3(@rsbuild/core@1.7.2) @@ -3415,6 +3418,14 @@ packages: engines: {node: '>=18.12.0'} hasBin: true + '@rsbuild/plugin-node-polyfill@1.4.4': + resolution: {integrity: sha512-V9Wh8FOprWBOaPvTK6ptj9A+SAkG1X1645sVf6HoIrJRNgtlrBECuadybMLFRBdihr6hiizz417d5RXy6X1hLQ==} + peerDependencies: + '@rsbuild/core': ^1.0.0 || ^2.0.0-0 + peerDependenciesMeta: + '@rsbuild/core': + optional: true + '@rsbuild/plugin-react@1.4.3': resolution: {integrity: sha512-Uf9FkKk2TqYDbsypXHgjdivPmEoYFvBTHJT5fPmjCf0Sc3lR2BHtlc0WMdZTCKUJ5jTxREygMs9LbnehX98L8g==} peerDependencies: @@ -4647,6 +4658,10 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + acorn-import-attributes@1.9.5: resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} peerDependencies: @@ -4840,6 +4855,9 @@ packages: asmcrypto.js@2.3.2: resolution: {integrity: sha512-3FgFARf7RupsZETQ1nHnhLUUvpcttcCq1iZCaVAbJZbCZ5VNRrNyvpDyHTOb0KC3llFcsyOT/a99NZcCbeiEsA==} + asn1.js@4.10.1: + resolution: {integrity: sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==} + assert-plus@1.0.0: resolution: {integrity: sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==} engines: {node: '>=0.8'} @@ -5066,6 +5084,23 @@ packages: browserify-aes@1.2.0: resolution: {integrity: sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==} + browserify-cipher@1.0.1: + resolution: {integrity: sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==} + + browserify-des@1.0.2: + resolution: {integrity: sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==} + + browserify-rsa@4.1.1: + resolution: {integrity: sha512-YBjSAiTqM04ZVei6sXighu679a3SqWORA3qZTEqZImnlkDIFtKc6pNutpjyZ8RJTjQtuYfeetkxM11GwoYXMIQ==} + engines: {node: '>= 0.10'} + + browserify-sign@4.2.5: + resolution: {integrity: sha512-C2AUdAJg6rlM2W5QMp2Q4KGQMVBwR1lIimTsUnutJ8bMpW5B52pGpR2gEnNBNwijumDo5FojQ0L9JrXA8m4YEw==} + engines: {node: '>= 0.10'} + + browserify-zlib@0.2.0: + resolution: {integrity: sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==} + browserslist@4.26.3: resolution: {integrity: sha512-lAUU+02RFBuCKQPj/P6NgjlbCnLBMp4UtgTx7vNHd3XSIJF87s9a5rA3aH2yw3GS9DqZAUbOtZdCCiZeVRqt0w==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -5099,6 +5134,9 @@ packages: resolution: {integrity: sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==} engines: {node: '>=6'} + builtin-status-codes@3.0.0: + resolution: {integrity: sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==} + bundle-require@5.1.0: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -5280,6 +5318,12 @@ packages: resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} engines: {node: ^14.18.0 || >=16.10.0} + console-browserify@1.2.0: + resolution: {integrity: sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==} + + constants-browserify@1.0.0: + resolution: {integrity: sha512-xFxOwqIzR/e1k1gLiWEophSCMqXcwVHIH7akf7b/vxcUeGunlj3hvZaaqxwHsTgn+IndtkQJgSztIDWeumWJDQ==} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -5316,6 +5360,9 @@ packages: engines: {node: '>=0.8'} hasBin: true + create-ecdh@4.0.4: + resolution: {integrity: sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==} + create-hash@1.2.0: resolution: {integrity: sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==} @@ -5335,6 +5382,10 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + crypto-browserify@3.12.1: + resolution: {integrity: sha512-r4ESw/IlusD17lgQi1O20Fa3qNnsckR126TdUuBgAu7GBYSIPvdNyONd3Zrxh0xCwA4+6w/TDArBPsMvhur+KQ==} + engines: {node: '>= 0.10'} + crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} @@ -5505,6 +5556,9 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + des.js@1.1.0: + resolution: {integrity: sha512-r17GxjhUCjSRy8aiJpr8/UadFIzMzJGexI3Nmz4ADi9LYSFx4gTBp80+NaX/YsXWWLhpZ7v/v/ubEc/bCNfKwg==} + detect-file@1.0.0: resolution: {integrity: sha512-DtCOLG98P007x7wiiOmfI0fi3eIKyWiLTGJ2MDnVi/E04lWGbf+JzrRHMm0rgIIZJGtHpKpbVgLWHrv8xXpc3Q==} engines: {node: '>=0.10.0'} @@ -5539,6 +5593,9 @@ packages: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} + diffie-hellman@5.0.3: + resolution: {integrity: sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==} + dijkstrajs@1.0.3: resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} @@ -5569,6 +5626,10 @@ packages: dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} + domain-browser@5.7.0: + resolution: {integrity: sha512-edTFu0M/7wO1pXY6GDxVNVW086uqwWYIHP98txhcPyV995X21JIH2DtYp33sQJOupYoXKe9RwTw2Ya2vWaquTQ==} + engines: {node: '>=4'} + domelementtype@2.3.0: resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==} @@ -5906,6 +5967,10 @@ packages: resolution: {integrity: sha512-CUnVOQq7gSpDHZVVrQW8ExxUETWrnrvXYvYz55wOU8Uj4VCgw56XC2B/fVqQN+f7gmrnRHSLVnFAwsCuNwji8w==} engines: {node: '>=6.5.0', npm: '>=3'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + events-universal@1.0.1: resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} @@ -6211,15 +6276,17 @@ packages: glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@9.3.5: resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} engines: {node: '>=16 || 14 >=14.17'} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-modules@1.0.0: resolution: {integrity: sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==} @@ -6286,6 +6353,10 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} + hash-base@3.0.5: + resolution: {integrity: sha512-vXm0l45VbcHEVlTCzs8M+s0VeYsB2lnlAaThoLKGXr3bE/VWDOelNUnycUPEhKEaXARL2TEFjBOyUiM6+55KBg==} + engines: {node: '>= 0.10'} + hash-base@3.1.2: resolution: {integrity: sha512-Bb33KbowVTIj5s7Ked1OsqHUeCpz//tPwR+E2zJgJKo9Z5XolZ9b6bdUgjmYlwnWhoOQKoTd1TYToZGn5mAYOg==} engines: {node: '>= 0.8'} @@ -6361,6 +6432,9 @@ packages: resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} engines: {node: '>= 14'} + https-browserify@1.0.0: + resolution: {integrity: sha512-J+FkSdyD+0mA0N+81tMotaRMfSL9SGi+xpD3T6YApKsc3bGSXJlfXri3VyFOeYkfLRQisDk1W+jIFFKBeUBbBg==} + https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} @@ -7113,6 +7187,10 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + miller-rabin@4.0.1: + resolution: {integrity: sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==} + hasBin: true + mime-db@1.25.0: resolution: {integrity: sha512-5k547tI4Cy+Lddr/hdjNbBEWBwSl8EBc5aSdKvedav8DReADgWJzcYiktaRIw3GtGC1jjwldXtTzvqJZmtvC7w==} engines: {node: '>= 0.6'} @@ -7406,6 +7484,9 @@ packages: orderedmap@2.1.1: resolution: {integrity: sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==} + os-browserify@0.3.0: + resolution: {integrity: sha512-gjcpUc3clBf9+210TRaDWbf+rZZZEshZ+DlXMRCeAjp0xhTrnQsKHypIy1J3d5hKdUzj69t708EHtU8P6bUn0A==} + outdent@0.5.0: resolution: {integrity: sha512-/jHxFIzoMXdqPzTaCpFzAAWhpkSjZPF4Vsn6jAfNpmbH/ymsmd7Qc6VE9BGn0L6YMj6uwpQLxCECpus4ukKS9Q==} @@ -7451,10 +7532,17 @@ packages: package-manager-detector@0.2.11: resolution: {integrity: sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-asn1@5.1.9: + resolution: {integrity: sha512-fIYNuZ/HastSb80baGOuPRo1O9cf4baWw5WsAp7dBuUzeTD/BoaG8sVTdlPFksBE2lF21dN+A1AnrpIjSWqHHg==} + engines: {node: '>= 0.10'} + parse-passwd@1.0.0: resolution: {integrity: sha512-1Y1A//QUXEZK7YKz+rD9WydcE1+EuPr6ZBgKecAB8tmoW6UFv0NREVJe1p+jRxtThkcbbKkfwIbWJe/IeE6m2Q==} engines: {node: '>=0.10.0'} @@ -7462,6 +7550,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@3.0.0: resolution: {integrity: sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==} engines: {node: '>=4'} @@ -7770,6 +7861,9 @@ packages: proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + public-encrypt@4.0.3: + resolution: {integrity: sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==} + pump@3.0.3: resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} @@ -7801,6 +7895,10 @@ packages: quansync@0.2.11: resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + querystring-es3@0.2.1: + resolution: {integrity: sha512-773xhDQnZBMFobEiztv8LIl70ch5MSF/jUQVlhwFyBILqq96anmoctVIYz+ZRp0qbCKATTn6ev02M3r7Ga5vqA==} + engines: {node: '>=0.4.x'} + querystring@0.2.1: resolution: {integrity: sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg==} engines: {node: '>=0.4.x'} @@ -7818,6 +7916,9 @@ packages: randombytes@2.1.0: resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==} + randomfill@1.0.4: + resolution: {integrity: sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==} + rc@1.2.8: resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} hasBin: true @@ -8001,6 +8102,10 @@ packages: resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} engines: {node: '>= 6'} + readable-stream@4.7.0: + resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + readdirp@3.6.0: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} @@ -8549,6 +8654,12 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + stream-browserify@3.0.0: + resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==} + + stream-http@3.2.0: + resolution: {integrity: sha512-Oq1bLqisTyK3TSCXpPbT4sdeYNdmyZJv1LxpEm2vu1ZhK89kSE5YXwZc3cWk0MagGaKriBh9mCFbVGtO+vY29A==} + streamx@2.23.0: resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} @@ -8788,6 +8899,10 @@ packages: resolution: {integrity: sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==} engines: {node: '>=10'} + timers-browserify@2.0.12: + resolution: {integrity: sha512-9phl76Cqm6FhSX9Xe1ZUAMLtm1BLkKj2Qd5ApyWkXzsMRaA7dgr81kf4wJmQf/hAvg8EEyJxDo3du/0KlhPiKQ==} + engines: {node: '>=0.6.0'} + tiny-case@1.0.3: resolution: {integrity: sha512-Eet/eeMhkO6TX8mnUteS9zgPbUMQa4I6Kkp5ORiBD5476/m+PIRiumP5tmh5ioJpH7k51Kehawy2UDfsnxxY8Q==} @@ -8937,6 +9052,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tty-browserify@0.0.1: + resolution: {integrity: sha512-C3TaO7K81YvjCgQH9Q1S3R3P3BtN3RIM8n+OvX4il1K1zgE8ZhI0op7kClgkxtutIE8hQrcrHBXvIheqKUUCxw==} + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -9292,6 +9410,9 @@ packages: jsdom: optional: true + vm-browserify@1.1.2: + resolution: {integrity: sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==} + w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} @@ -12985,6 +13106,34 @@ snapshots: core-js: 3.47.0 jiti: 2.6.1 + '@rsbuild/plugin-node-polyfill@1.4.4(@rsbuild/core@1.7.2)': + dependencies: + assert: 2.1.0 + browserify-zlib: 0.2.0 + buffer: 5.7.1 + console-browserify: 1.2.0 + constants-browserify: 1.0.0 + crypto-browserify: 3.12.1 + domain-browser: 5.7.0 + events: 3.3.0 + https-browserify: 1.0.0 + os-browserify: 0.3.0 + path-browserify: 1.0.1 + process: 0.11.10 + punycode: 2.3.1 + querystring-es3: 0.2.1 + readable-stream: 4.7.0 + stream-browserify: 3.0.0 + stream-http: 3.2.0 + string_decoder: 1.3.0 + timers-browserify: 2.0.12 + tty-browserify: 0.0.1 + url: 0.11.4 + util: 0.12.5 + vm-browserify: 1.1.2 + optionalDependencies: + '@rsbuild/core': 1.7.2 + '@rsbuild/plugin-react@1.4.3(@rsbuild/core@1.7.2)': dependencies: '@rsbuild/core': 1.7.2 @@ -14597,6 +14746,10 @@ snapshots: '@xtuc/long@4.2.2': {} + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + acorn-import-attributes@1.9.5(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -14802,6 +14955,12 @@ snapshots: asmcrypto.js@2.3.2: {} + asn1.js@4.10.1: + dependencies: + bn.js: 4.12.2 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + assert-plus@1.0.0: {} assert@2.1.0: @@ -15072,6 +15231,41 @@ snapshots: inherits: 2.0.4 safe-buffer: 5.2.1 + browserify-cipher@1.0.1: + dependencies: + browserify-aes: 1.2.0 + browserify-des: 1.0.2 + evp_bytestokey: 1.0.3 + + browserify-des@1.0.2: + dependencies: + cipher-base: 1.0.7 + des.js: 1.1.0 + inherits: 2.0.4 + safe-buffer: 5.2.1 + + browserify-rsa@4.1.1: + dependencies: + bn.js: 5.2.2 + randombytes: 2.1.0 + safe-buffer: 5.2.1 + + browserify-sign@4.2.5: + dependencies: + bn.js: 5.2.2 + browserify-rsa: 4.1.1 + create-hash: 1.2.0 + create-hmac: 1.1.7 + elliptic: 6.6.1 + inherits: 2.0.4 + parse-asn1: 5.1.9 + readable-stream: 2.3.8 + safe-buffer: 5.2.1 + + browserify-zlib@0.2.0: + dependencies: + pako: 1.0.11 + browserslist@4.26.3: dependencies: baseline-browser-mapping: 2.9.11 @@ -15110,6 +15304,8 @@ snapshots: builtin-modules@3.3.0: {} + builtin-status-codes@3.0.0: {} + bundle-require@5.1.0(esbuild@0.25.10): dependencies: esbuild: 0.25.10 @@ -15289,6 +15485,10 @@ snapshots: consola@3.4.2: {} + console-browserify@1.2.0: {} + + constants-browserify@1.0.0: {} + convert-source-map@2.0.0: {} cookie-es@2.0.0: {} @@ -15315,6 +15515,11 @@ snapshots: crc-32@1.2.2: {} + create-ecdh@4.0.4: + dependencies: + bn.js: 4.12.2 + elliptic: 6.6.1 + create-hash@1.2.0: dependencies: cipher-base: 1.0.7 @@ -15348,6 +15553,21 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + crypto-browserify@3.12.1: + dependencies: + browserify-cipher: 1.0.1 + browserify-sign: 4.2.5 + create-ecdh: 4.0.4 + create-hash: 1.2.0 + create-hmac: 1.1.7 + diffie-hellman: 5.0.3 + hash-base: 3.0.5 + inherits: 2.0.4 + pbkdf2: 3.1.5 + public-encrypt: 4.0.3 + randombytes: 2.1.0 + randomfill: 1.0.4 + crypto-js@4.2.0: {} crypto-random-string@2.0.0: {} @@ -15504,6 +15724,11 @@ snapshots: dequal@2.0.3: {} + des.js@1.1.0: + dependencies: + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + detect-file@1.0.0: {} detect-indent@6.1.0: {} @@ -15526,6 +15751,12 @@ snapshots: diff@8.0.3: {} + diffie-hellman@5.0.3: + dependencies: + bn.js: 4.12.2 + miller-rabin: 4.0.1 + randombytes: 2.1.0 + dijkstrajs@1.0.3: {} dir-glob@3.0.1: @@ -15557,6 +15788,8 @@ snapshots: domhandler: 5.0.3 entities: 4.5.0 + domain-browser@5.7.0: {} + domelementtype@2.3.0: {} domhandler@5.0.3: @@ -16205,6 +16438,8 @@ snapshots: is-hex-prefixed: 1.0.0 strip-hex-prefix: 1.0.0 + event-target-shim@5.0.1: {} + events-universal@1.0.1: dependencies: bare-events: 2.7.0 @@ -16616,6 +16851,11 @@ snapshots: dependencies: has-symbols: 1.1.0 + hash-base@3.0.5: + dependencies: + inherits: 2.0.4 + safe-buffer: 5.2.1 + hash-base@3.1.2: dependencies: inherits: 2.0.4 @@ -16717,6 +16957,8 @@ snapshots: transitivePeerDependencies: - supports-color + https-browserify@1.0.0: {} + https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -17459,6 +17701,11 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + miller-rabin@4.0.1: + dependencies: + bn.js: 4.12.2 + brorand: 1.1.0 + mime-db@1.25.0: {} mime-db@1.52.0: {} @@ -17756,6 +18003,8 @@ snapshots: orderedmap@2.1.1: {} + os-browserify@0.3.0: {} + outdent@0.5.0: {} own-keys@1.0.1: @@ -17798,16 +18047,28 @@ snapshots: dependencies: quansync: 0.2.11 + pako@1.0.11: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 + parse-asn1@5.1.9: + dependencies: + asn1.js: 4.10.1 + browserify-aes: 1.2.0 + evp_bytestokey: 1.0.3 + pbkdf2: 3.1.5 + safe-buffer: 5.2.1 + parse-passwd@1.0.0: {} parse5@7.3.0: dependencies: entities: 6.0.1 + path-browserify@1.0.1: {} + path-exists@3.0.0: {} path-exists@4.0.0: {} @@ -18151,6 +18412,15 @@ snapshots: proxy-from-env@1.1.0: {} + public-encrypt@4.0.3: + dependencies: + bn.js: 4.12.2 + browserify-rsa: 4.1.1 + create-hash: 1.2.0 + parse-asn1: 5.1.9 + randombytes: 2.1.0 + safe-buffer: 5.2.1 + pump@3.0.3: dependencies: end-of-stream: 1.4.5 @@ -18178,6 +18448,8 @@ snapshots: quansync@0.2.11: {} + querystring-es3@0.2.1: {} + querystring@0.2.1: {} querystringify@2.2.0: {} @@ -18190,6 +18462,11 @@ snapshots: dependencies: safe-buffer: 5.2.1 + randomfill@1.0.4: + dependencies: + randombytes: 2.1.0 + safe-buffer: 5.2.1 + rc@1.2.8: dependencies: deep-extend: 0.6.0 @@ -18450,6 +18727,14 @@ snapshots: string_decoder: 1.3.0 util-deprecate: 1.0.2 + readable-stream@4.7.0: + dependencies: + abort-controller: 3.0.0 + buffer: 6.0.3 + events: 3.3.0 + process: 0.11.10 + string_decoder: 1.3.0 + readdirp@3.6.0: dependencies: picomatch: 2.3.1 @@ -19105,6 +19390,18 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + stream-browserify@3.0.0: + dependencies: + inherits: 2.0.4 + readable-stream: 3.6.2 + + stream-http@3.2.0: + dependencies: + builtin-status-codes: 3.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + xtend: 4.0.2 + streamx@2.23.0: dependencies: events-universal: 1.0.1 @@ -19417,6 +19714,10 @@ snapshots: throttle-debounce@3.0.1: {} + timers-browserify@2.0.12: + dependencies: + setimmediate: 1.0.5 + tiny-case@1.0.3: {} tiny-invariant@1.2.0: {} @@ -19617,6 +19918,8 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tty-browserify@0.0.1: {} + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 @@ -20203,6 +20506,8 @@ snapshots: - tsx - yaml + vm-browserify@1.1.2: {} + w3c-keyname@2.2.8: {} w3c-xmlserializer@5.0.0: From 4cb12d3d888a2fa34abd362f6b44ce71df1fbf33 Mon Sep 17 00:00:00 2001 From: Ildar T Date: Sun, 22 Feb 2026 13:25:16 +0300 Subject: [PATCH 2/3] Changed to floating ui and filtered assets --- apps/self-hosted/package.json | 4 + apps/self-hosted/src/core/i18n.ts | 16 +++- .../tipping/components/tip-button.tsx | 28 ++++--- .../components/tipping-currency-cards.tsx | 13 +++- .../tipping/components/tipping-popover.tsx | 73 ++++++++++++------- .../components/tipping-step-currency.tsx | 67 ++++++++++++++--- .../tipping/components/tipping-wallet-qr.tsx | 55 ++++++++++++++ .../self-hosted/src/features/tipping/types.ts | 30 +++++++- pnpm-lock.yaml | 48 ++++++++---- 9 files changed, 269 insertions(+), 65 deletions(-) create mode 100644 apps/self-hosted/src/features/tipping/components/tipping-wallet-qr.tsx diff --git a/apps/self-hosted/package.json b/apps/self-hosted/package.json index b011e3cb8d..07bdea8849 100644 --- a/apps/self-hosted/package.json +++ b/apps/self-hosted/package.json @@ -11,6 +11,8 @@ "preview": "rsbuild preview" }, "dependencies": { + "@floating-ui/dom": "^1.7.4", + "@floating-ui/react-dom": "^2.1.6", "@ecency/render-helper": "workspace:*", "@ecency/sdk": "workspace:*", "@ecency/ui": "workspace:*", @@ -38,6 +40,7 @@ "hivesigner": "^3.3.5", "marked": "^12.0.0", "motion": "^12.23.22", + "qrcode": "^1.5.4", "qrcode.react": "^4.2.0", "react": "^19.1.1", "react-dom": "^19.1.1", @@ -59,6 +62,7 @@ "@types/dompurify": "^3.0.5", "@types/marked": "^6.0.0", "@types/node": "^24.6.0", + "@types/qrcode": "^1.5.5", "@types/react": "^19.1.16", "@types/react-dom": "^19.1.9", "@types/speakingurl": "^13.0.6", diff --git a/apps/self-hosted/src/core/i18n.ts b/apps/self-hosted/src/core/i18n.ts index 1a95638dcf..6a0e96156e 100644 --- a/apps/self-hosted/src/core/i18n.ts +++ b/apps/self-hosted/src/core/i18n.ts @@ -1,7 +1,7 @@ import { InstanceConfigManager } from './configuration-loader'; // Translation keys used throughout the app -type TranslationKey = +export type TranslationKey = | 'loading' | 'loadingPost' | 'loadingMore' @@ -64,6 +64,8 @@ type TranslationKey = | 'tip_custom' | 'tip_currency' | 'tip_private_key' + | 'tip_wallet_address' + | 'tip_no_wallet_address' | 'tip_send' | 'tip_sending' | 'cancel'; @@ -134,6 +136,8 @@ const translations: Record = { tip_custom: 'Custom', tip_currency: 'Currency', tip_private_key: 'Active key', + tip_wallet_address: 'Wallet address', + tip_no_wallet_address: 'Recipient has not set up this wallet address.', tip_send: 'Tip', tip_sending: 'Sending...', cancel: 'Cancel', @@ -201,6 +205,8 @@ const translations: Record = { tip_custom: 'Personalizado', tip_currency: 'Moneda', tip_private_key: 'Clave activa', + tip_wallet_address: 'Dirección de la billetera', + tip_no_wallet_address: 'El destinatario no ha configurado esta dirección.', tip_send: 'Propina', tip_sending: 'Enviando...', cancel: 'Cancelar', @@ -268,6 +274,8 @@ const translations: Record = { tip_custom: 'Benutzerdefiniert', tip_currency: 'Währung', tip_private_key: 'Aktiver Schlüssel', + tip_wallet_address: 'Wallet-Adresse', + tip_no_wallet_address: 'Empfänger hat diese Wallet-Adresse nicht eingerichtet.', tip_send: 'Trinkgeld', tip_sending: 'Wird gesendet...', cancel: 'Abbrechen', @@ -335,6 +343,8 @@ const translations: Record = { tip_custom: 'Personnalisé', tip_currency: 'Devise', tip_private_key: 'Clé active', + tip_wallet_address: 'Adresse du portefeuille', + tip_no_wallet_address: "Le destinataire n'a pas configuré cette adresse.", tip_send: 'Pourboire', tip_sending: 'Envoi...', cancel: 'Annuler', @@ -402,6 +412,8 @@ const translations: Record = { tip_custom: '사용자 지정', tip_currency: '통화', tip_private_key: '액티브 키', + tip_wallet_address: '지갑 주소', + tip_no_wallet_address: '수신자가 이 지갑 주소를 설정하지 않았습니다.', tip_send: '팁', tip_sending: '전송 중...', cancel: '취소', @@ -469,6 +481,8 @@ const translations: Record = { tip_custom: 'Своя', tip_currency: 'Валюта', tip_private_key: 'Активный ключ', + tip_wallet_address: 'Адрес кошелька', + tip_no_wallet_address: 'Получатель не настроил этот адрес кошелька.', tip_send: 'Отправить', tip_sending: 'Отправка...', cancel: 'Отмена', diff --git a/apps/self-hosted/src/features/tipping/components/tip-button.tsx b/apps/self-hosted/src/features/tipping/components/tip-button.tsx index 3ab40e3410..b9f972b4ac 100644 --- a/apps/self-hosted/src/features/tipping/components/tip-button.tsx +++ b/apps/self-hosted/src/features/tipping/components/tip-button.tsx @@ -1,10 +1,12 @@ -'use client'; +"use client"; -import { useRef, useState } from 'react'; -import { UilDollarSign } from '@tooni/iconscout-unicons-react'; -import { useTippingConfig } from '../hooks/use-tipping-config'; -import { TippingPopover } from './tipping-popover'; -import type { TippingVariant } from '../types'; +import { autoUpdate, offset } from "@floating-ui/dom"; +import { flip, shift, useFloating } from "@floating-ui/react-dom"; +import { useState } from "react"; +import { UilDollarSign } from "@tooni/iconscout-unicons-react"; +import { useTippingConfig } from "../hooks/use-tipping-config"; +import { TippingPopover } from "./tipping-popover"; +import type { TippingVariant } from "../types"; interface TipButtonProps { recipientUsername: string; @@ -16,19 +18,24 @@ interface TipButtonProps { export function TipButton({ recipientUsername, variant, - memo = '', + memo = "", className, }: TipButtonProps) { const { enabled, buttonLabel, presetAmounts } = useTippingConfig(variant); const [open, setOpen] = useState(false); - const anchorRef = useRef(undefined); + + const { refs, floatingStyles } = useFloating({ + placement: "bottom-start", + middleware: [offset(8), flip(), shift({ padding: 8 })], + whileElementsMounted: autoUpdate, + }); if (!enabled) return undefined; return ( <> - + {!isExternal && ( + + )}
); diff --git a/apps/self-hosted/src/features/tipping/components/tipping-wallet-qr.tsx b/apps/self-hosted/src/features/tipping/components/tipping-wallet-qr.tsx new file mode 100644 index 0000000000..4497278fd6 --- /dev/null +++ b/apps/self-hosted/src/features/tipping/components/tipping-wallet-qr.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useEffect, useState } from "react"; +import QRCode from "qrcode"; + +interface TippingWalletQrProps { + address: string; + size?: number; + className?: string; +} + +export function TippingWalletQr({ + address, + size = 180, + className, +}: TippingWalletQrProps) { + const [dataUrl, setDataUrl] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!address.trim()) { + setDataUrl(null); + setError("No address"); + return; + } + setError(null); + QRCode.toDataURL(address, { width: size, margin: 2 }) + .then(setDataUrl) + .catch((err: unknown) => setError(err instanceof Error ? err.message : "Failed to generate QR")); + }, [address, size]); + + if (error) { + return ( +
+ {error} +
+ ); + } + if (!dataUrl) { + return ( +
+ +
+ ); + } + return ( + Wallet address QR code + ); +} diff --git a/apps/self-hosted/src/features/tipping/types.ts b/apps/self-hosted/src/features/tipping/types.ts index e27b97bc23..efddfb8a3f 100644 --- a/apps/self-hosted/src/features/tipping/types.ts +++ b/apps/self-hosted/src/features/tipping/types.ts @@ -1,6 +1,32 @@ -export type TippingVariant = 'post' | 'general'; +export type TippingVariant = "post" | "general"; -export type TippingAsset = 'HIVE' | 'HBD' | 'POINTS'; +export type TippingAsset = "HIVE" | "HBD" | "POINTS"; + +/** Assets that require active key input for tipping */ +export const ASSETS_REQUIRING_KEY = ["POINTS", "HIVE", "HBD", "HP"] as const; + +/** External wallet symbols (show QR with address, no Tip button) */ +export const EXTERNAL_WALLET_SYMBOLS = [ + "APT", + "BNB", + "BTC", + "ETH", + "SOL", + "TON", + "TRX", +] as const; + +export function isAssetRequiringKey(asset: string): boolean { + return ASSETS_REQUIRING_KEY.includes( + asset as (typeof ASSETS_REQUIRING_KEY)[number], + ); +} + +export function isExternalWalletAsset(asset: string): boolean { + return EXTERNAL_WALLET_SYMBOLS.includes( + asset as (typeof EXTERNAL_WALLET_SYMBOLS)[number], + ); +} export interface TippingConfig { enabled: boolean; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d02fb8b611..4866277c20 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,6 +55,12 @@ importers: '@ecency/wallets': specifier: workspace:* version: link:../../packages/wallets + '@floating-ui/dom': + specifier: ^1.7.4 + version: 1.7.4 + '@floating-ui/react-dom': + specifier: ^2.1.6 + version: 2.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@hiveio/dhive': specifier: ^1.3.1-beta version: 1.3.2 @@ -124,6 +130,9 @@ importers: motion: specifier: ^12.23.22 version: 12.26.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + qrcode: + specifier: ^1.5.4 + version: 1.5.4 qrcode.react: specifier: ^4.2.0 version: 4.2.0(react@19.2.3) @@ -182,6 +191,9 @@ importers: '@types/node': specifier: ^24.6.0 version: 24.10.8 + '@types/qrcode': + specifier: ^1.5.5 + version: 1.5.5 '@types/react': specifier: ^19.1.16 version: 19.2.8 @@ -11926,6 +11938,12 @@ snapshots: react: 18.3.1 react-dom: 18.3.1(react@18.3.1) + '@floating-ui/react-dom@2.1.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@floating-ui/dom': 1.7.4 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + '@floating-ui/utils@0.2.10': {} '@hiveio/dhive@1.3.2': @@ -14083,7 +14101,7 @@ snapshots: '@types/bn.js@4.11.6': dependencies: - '@types/node': 22.18.8 + '@types/node': 24.10.8 '@types/bs58@4.0.4': dependencies: @@ -14096,7 +14114,7 @@ snapshots: '@types/connect@3.4.36': dependencies: - '@types/node': 22.18.8 + '@types/node': 24.10.8 '@types/crypto-js@4.2.2': {} @@ -14129,7 +14147,7 @@ snapshots: '@types/glob@7.2.0': dependencies: '@types/minimatch': 6.0.0 - '@types/node': 22.18.8 + '@types/node': 24.10.8 '@types/google.maps@3.58.1': {} @@ -14175,7 +14193,7 @@ snapshots: '@types/mysql@2.15.26': dependencies: - '@types/node': 22.18.8 + '@types/node': 24.10.8 '@types/node@12.20.55': {} @@ -14199,7 +14217,7 @@ snapshots: '@types/pbkdf2@3.1.2': dependencies: - '@types/node': 22.18.8 + '@types/node': 24.10.8 '@types/pg-pool@2.0.6': dependencies: @@ -14207,7 +14225,7 @@ snapshots: '@types/pg@8.6.1': dependencies: - '@types/node': 22.18.8 + '@types/node': 24.10.8 pg-protocol: 1.10.3 pg-types: 2.2.0 @@ -14215,7 +14233,7 @@ snapshots: '@types/qrcode@1.5.5': dependencies: - '@types/node': 22.18.8 + '@types/node': 24.10.8 '@types/react-beautiful-dnd@13.1.8': dependencies: @@ -14262,13 +14280,13 @@ snapshots: '@types/resolve@1.17.1': dependencies: - '@types/node': 22.18.8 + '@types/node': 24.10.8 '@types/rss@0.0.32': {} '@types/secp256k1@4.0.7': dependencies: - '@types/node': 22.18.8 + '@types/node': 24.10.8 '@types/shimmer@1.2.0': {} @@ -14278,7 +14296,7 @@ snapshots: '@types/tedious@4.0.14': dependencies: - '@types/node': 22.18.8 + '@types/node': 24.10.8 '@types/trusted-types@2.0.7': {} @@ -14640,7 +14658,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 1.2.0 - vitest: 2.1.9(@types/node@22.18.8)(@vitest/ui@2.1.9)(jsdom@26.1.0)(lightningcss@1.30.2)(sass-embedded@1.93.2)(sass@1.93.2)(terser@5.44.0) + vitest: 2.1.9(@types/node@20.19.19)(@vitest/ui@2.1.9)(jsdom@25.0.1)(lightningcss@1.30.2)(sass-embedded@1.93.2)(sass@1.93.2)(terser@5.44.0) '@vitest/utils@2.1.9': dependencies: @@ -17269,13 +17287,13 @@ snapshots: jest-worker@26.6.2: dependencies: - '@types/node': 22.18.8 + '@types/node': 24.10.8 merge-stream: 2.0.0 supports-color: 7.2.0 jest-worker@27.5.1: dependencies: - '@types/node': 22.18.8 + '@types/node': 24.10.8 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -18392,7 +18410,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 22.18.8 + '@types/node': 24.10.8 long: 5.2.4 protobufjs@7.5.4: @@ -18407,7 +18425,7 @@ snapshots: '@protobufjs/path': 1.1.2 '@protobufjs/pool': 1.1.0 '@protobufjs/utf8': 1.1.0 - '@types/node': 22.18.8 + '@types/node': 24.10.8 long: 5.3.2 proxy-from-env@1.1.0: {} From f5c64d03394978c3958eb8961758449965337c07 Mon Sep 17 00:00:00 2001 From: Ildar T Date: Sun, 22 Feb 2026 14:43:04 +0300 Subject: [PATCH 3/3] Changed tipping transaction to auth method --- apps/self-hosted/src/core/i18n.ts | 967 +++++++++--------- .../src/features/auth/auth-actions.ts | 12 +- .../components/tipping-currency-cards.tsx | 1 + .../tipping/components/tipping-popover.tsx | 49 +- .../components/tipping-step-currency.tsx | 42 +- .../tipping/components/tipping-wallet-qr.tsx | 7 +- .../self-hosted/src/features/tipping/types.ts | 6 + .../features/tipping/utils/tip-transaction.ts | 165 ++- 8 files changed, 608 insertions(+), 641 deletions(-) diff --git a/apps/self-hosted/src/core/i18n.ts b/apps/self-hosted/src/core/i18n.ts index 6a0e96156e..e885370b9d 100644 --- a/apps/self-hosted/src/core/i18n.ts +++ b/apps/self-hosted/src/core/i18n.ts @@ -1,491 +1,532 @@ -import { InstanceConfigManager } from './configuration-loader'; +import { InstanceConfigManager } from "./configuration-loader"; // Translation keys used throughout the app export type TranslationKey = - | 'loading' - | 'loadingPost' - | 'loadingMore' - | 'postNotFound' - | 'noPosts' - | 'followers' - | 'following' - | 'hiveInfo' - | 'reputation' - | 'joined' - | 'posts' - | 'location' - | 'website' - | 'likes' - | 'comments' - | 'reblogs' - | 'replies' - | 'blog' - | 'newest' - | 'trending' - | 'authorReputation' - | 'votes' - | 'discussion' - | 'readTime' - | 'minRead' - | 'login' - | 'logout' - | 'login_to_comment' - | 'login_to_vote' - | 'login_to_reblog' - | 'write_comment' - | 'posting' - | 'post_comment' - | 'create_post' - | 'subscribers' - | 'authors' - | 'community_info' - | 'created' - | 'language' - | 'pending_posts' - | 'team' - | 'search' - | 'searching' - | 'search_error' - | 'no_results' - | 'results_for' - | 'enter_search_query' - | 'listen' - | 'pause' - | 'resume' - | 'stop' - | 'reblogging' - | 'reblog_confirm' - | 'cant_reblog_own' - | 'already_reblogged' - | 'reblog_to_followers' - | 'error_loading' - | 'retry' - | 'tip_amount' - | 'tip_custom' - | 'tip_currency' - | 'tip_private_key' - | 'tip_wallet_address' - | 'tip_no_wallet_address' - | 'tip_send' - | 'tip_sending' - | 'cancel'; + | "loading" + | "loadingPost" + | "loadingMore" + | "postNotFound" + | "noPosts" + | "followers" + | "following" + | "hiveInfo" + | "reputation" + | "joined" + | "posts" + | "location" + | "website" + | "likes" + | "comments" + | "reblogs" + | "replies" + | "blog" + | "newest" + | "trending" + | "authorReputation" + | "votes" + | "discussion" + | "readTime" + | "minRead" + | "login" + | "logout" + | "login_to_comment" + | "login_to_vote" + | "login_to_reblog" + | "write_comment" + | "posting" + | "post_comment" + | "create_post" + | "subscribers" + | "authors" + | "community_info" + | "created" + | "language" + | "pending_posts" + | "team" + | "search" + | "searching" + | "search_error" + | "no_results" + | "results_for" + | "enter_search_query" + | "listen" + | "pause" + | "resume" + | "stop" + | "reblogging" + | "reblog_confirm" + | "cant_reblog_own" + | "already_reblogged" + | "reblog_to_followers" + | "error_loading" + | "retry" + | "tip_amount" + | "tip_custom" + | "tip_currency" + | "tip_private_key" + | "tip_wallet_address" + | "tip_no_wallet_address" + | "tip_send" + | "tip_sending" + | "tip_login_to_send" + | "tip_asset_not_supported" + | "tip_transaction_failed" + | "tip_qr_no_address" + | "tip_qr_failed" + | "cancel"; type Translations = Record; const translations: Record = { en: { - loading: 'Loading...', - loadingPost: 'Loading post...', - loadingMore: 'Loading more posts...', - postNotFound: 'Post not found.', - noPosts: 'No posts found.', - followers: 'Followers', - following: 'Following', - hiveInfo: 'Hive Info', - reputation: 'Reputation', - joined: 'Joined', - posts: 'Posts', - location: 'Location', - website: 'Website', - likes: 'likes', - comments: 'comments', - reblogs: 'reblogs', - replies: 'Replies', - blog: 'Blog', - newest: 'Newest', - trending: 'Trending', - authorReputation: 'Author Reputation', - votes: 'Votes', - discussion: 'Discussion', - readTime: 'read', - minRead: 'min read', - login: 'Login', - logout: 'Logout', - login_to_comment: 'Login to leave a comment', - login_to_vote: 'Login to vote', - login_to_reblog: 'Login to reblog', - write_comment: 'Write a comment...', - posting: 'Posting...', - post_comment: 'Post Comment', - create_post: 'Create Post', - subscribers: 'Subscribers', - authors: 'Authors', - community_info: 'Community Info', - created: 'Created', - language: 'Language', - pending_posts: 'Pending Posts', - team: 'Team', - search: 'Search', - searching: 'Searching...', - search_error: 'Search failed. Please try again.', - no_results: 'No results found.', - results_for: 'results for', - enter_search_query: 'Enter a search term to find posts.', - listen: 'Listen', - pause: 'Pause', - resume: 'Resume', - stop: 'Stop', - reblogging: 'Reblogging...', - reblog_confirm: 'Are you sure you want to reblog this post to your followers?', + loading: "Loading...", + loadingPost: "Loading post...", + loadingMore: "Loading more posts...", + postNotFound: "Post not found.", + noPosts: "No posts found.", + followers: "Followers", + following: "Following", + hiveInfo: "Hive Info", + reputation: "Reputation", + joined: "Joined", + posts: "Posts", + location: "Location", + website: "Website", + likes: "likes", + comments: "comments", + reblogs: "reblogs", + replies: "Replies", + blog: "Blog", + newest: "Newest", + trending: "Trending", + authorReputation: "Author Reputation", + votes: "Votes", + discussion: "Discussion", + readTime: "read", + minRead: "min read", + login: "Login", + logout: "Logout", + login_to_comment: "Login to leave a comment", + login_to_vote: "Login to vote", + login_to_reblog: "Login to reblog", + write_comment: "Write a comment...", + posting: "Posting...", + post_comment: "Post Comment", + create_post: "Create Post", + subscribers: "Subscribers", + authors: "Authors", + community_info: "Community Info", + created: "Created", + language: "Language", + pending_posts: "Pending Posts", + team: "Team", + search: "Search", + searching: "Searching...", + search_error: "Search failed. Please try again.", + no_results: "No results found.", + results_for: "results for", + enter_search_query: "Enter a search term to find posts.", + listen: "Listen", + pause: "Pause", + resume: "Resume", + stop: "Stop", + reblogging: "Reblogging...", + reblog_confirm: + "Are you sure you want to reblog this post to your followers?", cant_reblog_own: "You can't reblog your own post", - already_reblogged: 'Already reblogged', - reblog_to_followers: 'Reblog to your followers', - error_loading: 'Something went wrong. Please try again.', - retry: 'Retry', - tip_amount: 'Amount', - tip_custom: 'Custom', - tip_currency: 'Currency', - tip_private_key: 'Active key', - tip_wallet_address: 'Wallet address', - tip_no_wallet_address: 'Recipient has not set up this wallet address.', - tip_send: 'Tip', - tip_sending: 'Sending...', - cancel: 'Cancel', + already_reblogged: "Already reblogged", + reblog_to_followers: "Reblog to your followers", + error_loading: "Something went wrong. Please try again.", + retry: "Retry", + tip_amount: "Amount", + tip_custom: "Custom", + tip_currency: "Currency", + tip_private_key: "Active key", + tip_wallet_address: "Wallet address", + tip_no_wallet_address: "Recipient has not set up this wallet address.", + tip_send: "Tip", + tip_sending: "Sending...", + tip_login_to_send: "Login to send a tip", + tip_asset_not_supported: "This asset is not supported for tipping yet", + tip_transaction_failed: "Transaction failed", + tip_qr_no_address: "No address", + tip_qr_failed: "Failed to generate QR", + cancel: "Cancel", }, es: { - loading: 'Cargando...', - loadingPost: 'Cargando publicación...', - loadingMore: 'Cargando más publicaciones...', - postNotFound: 'Publicación no encontrada.', - noPosts: 'No se encontraron publicaciones.', - followers: 'Seguidores', - following: 'Siguiendo', - hiveInfo: 'Info de Hive', - reputation: 'Reputación', - joined: 'Se unió', - posts: 'Publicaciones', - location: 'Ubicación', - website: 'Sitio web', - likes: 'me gusta', - comments: 'comentarios', - reblogs: 'reblogueos', - replies: 'Respuestas', - blog: 'Blog', - newest: 'Más reciente', - trending: 'Tendencia', - authorReputation: 'Reputación del autor', - votes: 'Votos', - discussion: 'Discusión', - readTime: 'lectura', - minRead: 'min de lectura', - login: 'Iniciar sesión', - logout: 'Cerrar sesión', - login_to_comment: 'Inicia sesión para comentar', - login_to_vote: 'Inicia sesión para votar', - login_to_reblog: 'Inicia sesión para rebloguear', - write_comment: 'Escribe un comentario...', - posting: 'Publicando...', - post_comment: 'Publicar comentario', - create_post: 'Crear publicación', - subscribers: 'Suscriptores', - authors: 'Autores', - community_info: 'Info de Comunidad', - created: 'Creado', - language: 'Idioma', - pending_posts: 'Posts Pendientes', - team: 'Equipo', - search: 'Buscar', - searching: 'Buscando...', - search_error: 'Error en la búsqueda. Intente de nuevo.', - no_results: 'No se encontraron resultados.', - results_for: 'resultados para', - enter_search_query: 'Ingrese un término para buscar publicaciones.', - listen: 'Escuchar', - pause: 'Pausar', - resume: 'Reanudar', - stop: 'Detener', - reblogging: 'Reblogueando...', - reblog_confirm: '¿Estás seguro de que quieres rebloguear esta publicación a tus seguidores?', - cant_reblog_own: 'No puedes rebloguear tu propia publicación', - already_reblogged: 'Ya reblogueado', - reblog_to_followers: 'Rebloguear a tus seguidores', - error_loading: 'Algo salió mal. Por favor, intente de nuevo.', - retry: 'Reintentar', - tip_amount: 'Monto', - tip_custom: 'Personalizado', - tip_currency: 'Moneda', - tip_private_key: 'Clave activa', - tip_wallet_address: 'Dirección de la billetera', - tip_no_wallet_address: 'El destinatario no ha configurado esta dirección.', - tip_send: 'Propina', - tip_sending: 'Enviando...', - cancel: 'Cancelar', + loading: "Cargando...", + loadingPost: "Cargando publicación...", + loadingMore: "Cargando más publicaciones...", + postNotFound: "Publicación no encontrada.", + noPosts: "No se encontraron publicaciones.", + followers: "Seguidores", + following: "Siguiendo", + hiveInfo: "Info de Hive", + reputation: "Reputación", + joined: "Se unió", + posts: "Publicaciones", + location: "Ubicación", + website: "Sitio web", + likes: "me gusta", + comments: "comentarios", + reblogs: "reblogueos", + replies: "Respuestas", + blog: "Blog", + newest: "Más reciente", + trending: "Tendencia", + authorReputation: "Reputación del autor", + votes: "Votos", + discussion: "Discusión", + readTime: "lectura", + minRead: "min de lectura", + login: "Iniciar sesión", + logout: "Cerrar sesión", + login_to_comment: "Inicia sesión para comentar", + login_to_vote: "Inicia sesión para votar", + login_to_reblog: "Inicia sesión para rebloguear", + write_comment: "Escribe un comentario...", + posting: "Publicando...", + post_comment: "Publicar comentario", + create_post: "Crear publicación", + subscribers: "Suscriptores", + authors: "Autores", + community_info: "Info de Comunidad", + created: "Creado", + language: "Idioma", + pending_posts: "Posts Pendientes", + team: "Equipo", + search: "Buscar", + searching: "Buscando...", + search_error: "Error en la búsqueda. Intente de nuevo.", + no_results: "No se encontraron resultados.", + results_for: "resultados para", + enter_search_query: "Ingrese un término para buscar publicaciones.", + listen: "Escuchar", + pause: "Pausar", + resume: "Reanudar", + stop: "Detener", + reblogging: "Reblogueando...", + reblog_confirm: + "¿Estás seguro de que quieres rebloguear esta publicación a tus seguidores?", + cant_reblog_own: "No puedes rebloguear tu propia publicación", + already_reblogged: "Ya reblogueado", + reblog_to_followers: "Rebloguear a tus seguidores", + error_loading: "Algo salió mal. Por favor, intente de nuevo.", + retry: "Reintentar", + tip_amount: "Monto", + tip_custom: "Personalizado", + tip_currency: "Moneda", + tip_private_key: "Clave activa", + tip_wallet_address: "Dirección de la billetera", + tip_no_wallet_address: "El destinatario no ha configurado esta dirección.", + tip_send: "Propina", + tip_sending: "Enviando...", + tip_login_to_send: "Inicia sesión para enviar una propina", + tip_asset_not_supported: "Este activo aún no es compatible para propinas", + tip_transaction_failed: "Error en la transacción", + tip_qr_no_address: "Sin dirección", + tip_qr_failed: "Error al generar el código QR", + cancel: "Cancelar", }, de: { - loading: 'Lädt...', - loadingPost: 'Beitrag wird geladen...', - loadingMore: 'Weitere Beiträge laden...', - postNotFound: 'Beitrag nicht gefunden.', - noPosts: 'Keine Beiträge gefunden.', - followers: 'Follower', - following: 'Folgt', - hiveInfo: 'Hive-Info', - reputation: 'Reputation', - joined: 'Beigetreten', - posts: 'Beiträge', - location: 'Standort', - website: 'Webseite', - likes: 'Gefällt mir', - comments: 'Kommentare', - reblogs: 'Reblogs', - replies: 'Antworten', - blog: 'Blog', - newest: 'Neueste', - trending: 'Trending', - authorReputation: 'Autoren-Reputation', - votes: 'Stimmen', - discussion: 'Diskussion', - readTime: 'Lesezeit', - minRead: 'Min. Lesezeit', - login: 'Anmelden', - logout: 'Abmelden', - login_to_comment: 'Melden Sie sich an, um zu kommentieren', - login_to_vote: 'Melden Sie sich an, um abzustimmen', - login_to_reblog: 'Melden Sie sich an, um zu rebloggen', - write_comment: 'Schreibe einen Kommentar...', - posting: 'Wird gepostet...', - post_comment: 'Kommentar posten', - create_post: 'Beitrag erstellen', - subscribers: 'Abonnenten', - authors: 'Autoren', - community_info: 'Community-Info', - created: 'Erstellt', - language: 'Sprache', - pending_posts: 'Ausstehende Beiträge', - team: 'Team', - search: 'Suchen', - searching: 'Suche...', - search_error: 'Suche fehlgeschlagen. Bitte erneut versuchen.', - no_results: 'Keine Ergebnisse gefunden.', - results_for: 'Ergebnisse für', - enter_search_query: 'Geben Sie einen Suchbegriff ein.', - listen: 'Anhören', - pause: 'Pause', - resume: 'Fortsetzen', - stop: 'Stopp', - reblogging: 'Rebloggen...', - reblog_confirm: 'Möchten Sie diesen Beitrag wirklich an Ihre Follower rebloggen?', - cant_reblog_own: 'Sie können Ihren eigenen Beitrag nicht rebloggen', - already_reblogged: 'Bereits rebloggt', - reblog_to_followers: 'An Ihre Follower rebloggen', - error_loading: 'Etwas ist schief gelaufen. Bitte versuchen Sie es erneut.', - retry: 'Erneut versuchen', - tip_amount: 'Betrag', - tip_custom: 'Benutzerdefiniert', - tip_currency: 'Währung', - tip_private_key: 'Aktiver Schlüssel', - tip_wallet_address: 'Wallet-Adresse', - tip_no_wallet_address: 'Empfänger hat diese Wallet-Adresse nicht eingerichtet.', - tip_send: 'Trinkgeld', - tip_sending: 'Wird gesendet...', - cancel: 'Abbrechen', + loading: "Lädt...", + loadingPost: "Beitrag wird geladen...", + loadingMore: "Weitere Beiträge laden...", + postNotFound: "Beitrag nicht gefunden.", + noPosts: "Keine Beiträge gefunden.", + followers: "Follower", + following: "Folgt", + hiveInfo: "Hive-Info", + reputation: "Reputation", + joined: "Beigetreten", + posts: "Beiträge", + location: "Standort", + website: "Webseite", + likes: "Gefällt mir", + comments: "Kommentare", + reblogs: "Reblogs", + replies: "Antworten", + blog: "Blog", + newest: "Neueste", + trending: "Trending", + authorReputation: "Autoren-Reputation", + votes: "Stimmen", + discussion: "Diskussion", + readTime: "Lesezeit", + minRead: "Min. Lesezeit", + login: "Anmelden", + logout: "Abmelden", + login_to_comment: "Melden Sie sich an, um zu kommentieren", + login_to_vote: "Melden Sie sich an, um abzustimmen", + login_to_reblog: "Melden Sie sich an, um zu rebloggen", + write_comment: "Schreibe einen Kommentar...", + posting: "Wird gepostet...", + post_comment: "Kommentar posten", + create_post: "Beitrag erstellen", + subscribers: "Abonnenten", + authors: "Autoren", + community_info: "Community-Info", + created: "Erstellt", + language: "Sprache", + pending_posts: "Ausstehende Beiträge", + team: "Team", + search: "Suchen", + searching: "Suche...", + search_error: "Suche fehlgeschlagen. Bitte erneut versuchen.", + no_results: "Keine Ergebnisse gefunden.", + results_for: "Ergebnisse für", + enter_search_query: "Geben Sie einen Suchbegriff ein.", + listen: "Anhören", + pause: "Pause", + resume: "Fortsetzen", + stop: "Stopp", + reblogging: "Rebloggen...", + reblog_confirm: + "Möchten Sie diesen Beitrag wirklich an Ihre Follower rebloggen?", + cant_reblog_own: "Sie können Ihren eigenen Beitrag nicht rebloggen", + already_reblogged: "Bereits rebloggt", + reblog_to_followers: "An Ihre Follower rebloggen", + error_loading: "Etwas ist schief gelaufen. Bitte versuchen Sie es erneut.", + retry: "Erneut versuchen", + tip_amount: "Betrag", + tip_custom: "Benutzerdefiniert", + tip_currency: "Währung", + tip_private_key: "Aktiver Schlüssel", + tip_wallet_address: "Wallet-Adresse", + tip_no_wallet_address: + "Empfänger hat diese Wallet-Adresse nicht eingerichtet.", + tip_send: "Trinkgeld", + tip_sending: "Wird gesendet...", + tip_login_to_send: "Melden Sie sich an, um ein Trinkgeld zu senden", + tip_asset_not_supported: "Dieser Vermögenswert wird für Trinkgeld noch nicht unterstützt", + tip_transaction_failed: "Transaktion fehlgeschlagen", + tip_qr_no_address: "Keine Adresse", + tip_qr_failed: "QR-Code konnte nicht erstellt werden", + cancel: "Abbrechen", }, fr: { - loading: 'Chargement...', + loading: "Chargement...", loadingPost: "Chargement de l'article...", loadingMore: "Chargement d'autres articles...", - postNotFound: 'Article non trouvé.', - noPosts: 'Aucun article trouvé.', - followers: 'Abonnés', - following: 'Abonnements', - hiveInfo: 'Info Hive', - reputation: 'Réputation', - joined: 'Inscrit', - posts: 'Articles', - location: 'Lieu', - website: 'Site web', + postNotFound: "Article non trouvé.", + noPosts: "Aucun article trouvé.", + followers: "Abonnés", + following: "Abonnements", + hiveInfo: "Info Hive", + reputation: "Réputation", + joined: "Inscrit", + posts: "Articles", + location: "Lieu", + website: "Site web", likes: "j'aime", - comments: 'commentaires', - reblogs: 'repartages', - replies: 'Réponses', - blog: 'Blog', - newest: 'Plus récent', - trending: 'Tendances', + comments: "commentaires", + reblogs: "repartages", + replies: "Réponses", + blog: "Blog", + newest: "Plus récent", + trending: "Tendances", authorReputation: "Réputation de l'auteur", - votes: 'Votes', - discussion: 'Discussion', - readTime: 'lecture', - minRead: 'min de lecture', - login: 'Connexion', - logout: 'Déconnexion', - login_to_comment: 'Connectez-vous pour commenter', - login_to_vote: 'Connectez-vous pour voter', - login_to_reblog: 'Connectez-vous pour repartager', - write_comment: 'Écrire un commentaire...', - posting: 'Publication...', - post_comment: 'Publier le commentaire', - create_post: 'Créer un article', - subscribers: 'Abonnés', - authors: 'Auteurs', - community_info: 'Info Communauté', - created: 'Créé', - language: 'Langue', - pending_posts: 'Articles en attente', - team: 'Équipe', - search: 'Rechercher', - searching: 'Recherche...', - search_error: 'La recherche a échoué. Veuillez réessayer.', - no_results: 'Aucun résultat trouvé.', - results_for: 'résultats pour', - enter_search_query: 'Entrez un terme pour rechercher des articles.', - listen: 'Écouter', - pause: 'Pause', - resume: 'Reprendre', - stop: 'Arrêter', - reblogging: 'Repartage...', - reblog_confirm: 'Êtes-vous sûr de vouloir repartager cet article à vos abonnés?', - cant_reblog_own: 'Vous ne pouvez pas repartager votre propre article', - already_reblogged: 'Déjà repartagé', - reblog_to_followers: 'Repartager à vos abonnés', + votes: "Votes", + discussion: "Discussion", + readTime: "lecture", + minRead: "min de lecture", + login: "Connexion", + logout: "Déconnexion", + login_to_comment: "Connectez-vous pour commenter", + login_to_vote: "Connectez-vous pour voter", + login_to_reblog: "Connectez-vous pour repartager", + write_comment: "Écrire un commentaire...", + posting: "Publication...", + post_comment: "Publier le commentaire", + create_post: "Créer un article", + subscribers: "Abonnés", + authors: "Auteurs", + community_info: "Info Communauté", + created: "Créé", + language: "Langue", + pending_posts: "Articles en attente", + team: "Équipe", + search: "Rechercher", + searching: "Recherche...", + search_error: "La recherche a échoué. Veuillez réessayer.", + no_results: "Aucun résultat trouvé.", + results_for: "résultats pour", + enter_search_query: "Entrez un terme pour rechercher des articles.", + listen: "Écouter", + pause: "Pause", + resume: "Reprendre", + stop: "Arrêter", + reblogging: "Repartage...", + reblog_confirm: + "Êtes-vous sûr de vouloir repartager cet article à vos abonnés?", + cant_reblog_own: "Vous ne pouvez pas repartager votre propre article", + already_reblogged: "Déjà repartagé", + reblog_to_followers: "Repartager à vos abonnés", error_loading: "Une erreur s'est produite. Veuillez réessayer.", - retry: 'Réessayer', - tip_amount: 'Montant', - tip_custom: 'Personnalisé', - tip_currency: 'Devise', - tip_private_key: 'Clé active', - tip_wallet_address: 'Adresse du portefeuille', + retry: "Réessayer", + tip_amount: "Montant", + tip_custom: "Personnalisé", + tip_currency: "Devise", + tip_private_key: "Clé active", + tip_wallet_address: "Adresse du portefeuille", tip_no_wallet_address: "Le destinataire n'a pas configuré cette adresse.", - tip_send: 'Pourboire', - tip_sending: 'Envoi...', - cancel: 'Annuler', + tip_send: "Pourboire", + tip_sending: "Envoi...", + tip_login_to_send: "Connectez-vous pour envoyer un pourboire", + tip_asset_not_supported: "Cet actif n'est pas encore pris en charge pour les pourboires", + tip_transaction_failed: "Échec de la transaction", + tip_qr_no_address: "Aucune adresse", + tip_qr_failed: "Échec de la génération du QR", + cancel: "Annuler", }, ko: { - loading: '로딩 중...', - loadingPost: '게시물 로딩 중...', - loadingMore: '더 많은 게시물 로딩 중...', - postNotFound: '게시물을 찾을 수 없습니다.', - noPosts: '게시물이 없습니다.', - followers: '팔로워', - following: '팔로잉', - hiveInfo: 'Hive 정보', - reputation: '평판', - joined: '가입일', - posts: '게시물', - location: '위치', - website: '웹사이트', - likes: '좋아요', - comments: '댓글', - reblogs: '리블로그', - replies: '답글', - blog: '블로그', - newest: '최신', - trending: '인기', - authorReputation: '작성자 평판', - votes: '투표', - discussion: '토론', - readTime: '읽기', - minRead: '분 읽기', - login: '로그인', - logout: '로그아웃', - login_to_comment: '댓글을 남기려면 로그인하세요', - login_to_vote: '투표하려면 로그인하세요', - login_to_reblog: '리블로그하려면 로그인하세요', - write_comment: '댓글 작성...', - posting: '게시 중...', - post_comment: '댓글 게시', - create_post: '게시물 작성', - subscribers: '구독자', - authors: '작성자', - community_info: '커뮤니티 정보', - created: '생성됨', - language: '언어', - pending_posts: '대기 중인 게시물', - team: '팀', - search: '검색', - searching: '검색 중...', - search_error: '검색에 실패했습니다. 다시 시도해주세요.', - no_results: '결과가 없습니다.', - results_for: '검색 결과', - enter_search_query: '검색어를 입력하세요.', - listen: '듣기', - pause: '일시정지', - resume: '재개', - stop: '정지', - reblogging: '리블로그 중...', - reblog_confirm: '이 게시물을 팔로워들에게 리블로그하시겠습니까?', - cant_reblog_own: '자신의 게시물은 리블로그할 수 없습니다', - already_reblogged: '이미 리블로그됨', - reblog_to_followers: '팔로워에게 리블로그', - error_loading: '문제가 발생했습니다. 다시 시도해주세요.', - retry: '다시 시도', - tip_amount: '금액', - tip_custom: '사용자 지정', - tip_currency: '통화', - tip_private_key: '액티브 키', - tip_wallet_address: '지갑 주소', - tip_no_wallet_address: '수신자가 이 지갑 주소를 설정하지 않았습니다.', - tip_send: '팁', - tip_sending: '전송 중...', - cancel: '취소', + loading: "로딩 중...", + loadingPost: "게시물 로딩 중...", + loadingMore: "더 많은 게시물 로딩 중...", + postNotFound: "게시물을 찾을 수 없습니다.", + noPosts: "게시물이 없습니다.", + followers: "팔로워", + following: "팔로잉", + hiveInfo: "Hive 정보", + reputation: "평판", + joined: "가입일", + posts: "게시물", + location: "위치", + website: "웹사이트", + likes: "좋아요", + comments: "댓글", + reblogs: "리블로그", + replies: "답글", + blog: "블로그", + newest: "최신", + trending: "인기", + authorReputation: "작성자 평판", + votes: "투표", + discussion: "토론", + readTime: "읽기", + minRead: "분 읽기", + login: "로그인", + logout: "로그아웃", + login_to_comment: "댓글을 남기려면 로그인하세요", + login_to_vote: "투표하려면 로그인하세요", + login_to_reblog: "리블로그하려면 로그인하세요", + write_comment: "댓글 작성...", + posting: "게시 중...", + post_comment: "댓글 게시", + create_post: "게시물 작성", + subscribers: "구독자", + authors: "작성자", + community_info: "커뮤니티 정보", + created: "생성됨", + language: "언어", + pending_posts: "대기 중인 게시물", + team: "팀", + search: "검색", + searching: "검색 중...", + search_error: "검색에 실패했습니다. 다시 시도해주세요.", + no_results: "결과가 없습니다.", + results_for: "검색 결과", + enter_search_query: "검색어를 입력하세요.", + listen: "듣기", + pause: "일시정지", + resume: "재개", + stop: "정지", + reblogging: "리블로그 중...", + reblog_confirm: "이 게시물을 팔로워들에게 리블로그하시겠습니까?", + cant_reblog_own: "자신의 게시물은 리블로그할 수 없습니다", + already_reblogged: "이미 리블로그됨", + reblog_to_followers: "팔로워에게 리블로그", + error_loading: "문제가 발생했습니다. 다시 시도해주세요.", + retry: "다시 시도", + tip_amount: "금액", + tip_custom: "사용자 지정", + tip_currency: "통화", + tip_private_key: "액티브 키", + tip_wallet_address: "지갑 주소", + tip_no_wallet_address: "수신자가 이 지갑 주소를 설정하지 않았습니다.", + tip_send: "팁", + tip_sending: "전송 중...", + tip_login_to_send: "팁을 보내려면 로그인하세요", + tip_asset_not_supported: "이 자산은 아직 팁을 지원하지 않습니다", + tip_transaction_failed: "거래 실패", + tip_qr_no_address: "주소 없음", + tip_qr_failed: "QR 코드 생성 실패", + cancel: "취소", }, ru: { - loading: 'Загрузка...', - loadingPost: 'Загрузка поста...', - loadingMore: 'Загрузка постов...', - postNotFound: 'Пост не найден.', - noPosts: 'Посты не найдены.', - followers: 'Подписчики', - following: 'Подписки', - hiveInfo: 'Инфо Hive', - reputation: 'Репутация', - joined: 'Присоединился', - posts: 'Посты', - location: 'Местоположение', - website: 'Веб-сайт', - likes: 'лайков', - comments: 'комментариев', - reblogs: 'реблогов', - replies: 'Ответы', - blog: 'Блог', - newest: 'Новые', - trending: 'Популярные', - authorReputation: 'Репутация автора', - votes: 'Голоса', - discussion: 'Обсуждение', - readTime: 'чтение', - minRead: 'мин чтения', - login: 'Вход', - logout: 'Выход', - login_to_comment: 'Войдите, чтобы оставить комментарий', - login_to_vote: 'Войдите, чтобы проголосовать', - login_to_reblog: 'Войдите, чтобы сделать реблог', - write_comment: 'Написать комментарий...', - posting: 'Публикация...', - post_comment: 'Опубликовать', - create_post: 'Создать пост', - subscribers: 'Подписчики', - authors: 'Авторы', - community_info: 'Информация о сообществе', - created: 'Создано', - language: 'Язык', - pending_posts: 'Ожидающие посты', - team: 'Команда', - search: 'Поиск', - searching: 'Поиск...', - search_error: 'Ошибка поиска. Попробуйте снова.', - no_results: 'Результаты не найдены.', - results_for: 'результатов для', - enter_search_query: 'Введите поисковый запрос.', - listen: 'Слушать', - pause: 'Пауза', - resume: 'Продолжить', - stop: 'Стоп', - reblogging: 'Реблог...', - reblog_confirm: 'Вы уверены, что хотите сделать реблог этого поста для ваших подписчиков?', - cant_reblog_own: 'Вы не можете сделать реблог своего поста', - already_reblogged: 'Уже сделан реблог', - reblog_to_followers: 'Сделать реблог для подписчиков', - error_loading: 'Что-то пошло не так. Пожалуйста, попробуйте снова.', - retry: 'Повторить', - tip_amount: 'Сумма', - tip_custom: 'Своя', - tip_currency: 'Валюта', - tip_private_key: 'Активный ключ', - tip_wallet_address: 'Адрес кошелька', - tip_no_wallet_address: 'Получатель не настроил этот адрес кошелька.', - tip_send: 'Отправить', - tip_sending: 'Отправка...', - cancel: 'Отмена', + loading: "Загрузка...", + loadingPost: "Загрузка поста...", + loadingMore: "Загрузка постов...", + postNotFound: "Пост не найден.", + noPosts: "Посты не найдены.", + followers: "Подписчики", + following: "Подписки", + hiveInfo: "Инфо Hive", + reputation: "Репутация", + joined: "Присоединился", + posts: "Посты", + location: "Местоположение", + website: "Веб-сайт", + likes: "лайков", + comments: "комментариев", + reblogs: "реблогов", + replies: "Ответы", + blog: "Блог", + newest: "Новые", + trending: "Популярные", + authorReputation: "Репутация автора", + votes: "Голоса", + discussion: "Обсуждение", + readTime: "чтение", + minRead: "мин чтения", + login: "Вход", + logout: "Выход", + login_to_comment: "Войдите, чтобы оставить комментарий", + login_to_vote: "Войдите, чтобы проголосовать", + login_to_reblog: "Войдите, чтобы сделать реблог", + write_comment: "Написать комментарий...", + posting: "Публикация...", + post_comment: "Опубликовать", + create_post: "Создать пост", + subscribers: "Подписчики", + authors: "Авторы", + community_info: "Информация о сообществе", + created: "Создано", + language: "Язык", + pending_posts: "Ожидающие посты", + team: "Команда", + search: "Поиск", + searching: "Поиск...", + search_error: "Ошибка поиска. Попробуйте снова.", + no_results: "Результаты не найдены.", + results_for: "результатов для", + enter_search_query: "Введите поисковый запрос.", + listen: "Слушать", + pause: "Пауза", + resume: "Продолжить", + stop: "Стоп", + reblogging: "Реблог...", + reblog_confirm: + "Вы уверены, что хотите сделать реблог этого поста для ваших подписчиков?", + cant_reblog_own: "Вы не можете сделать реблог своего поста", + already_reblogged: "Уже сделан реблог", + reblog_to_followers: "Сделать реблог для подписчиков", + error_loading: "Что-то пошло не так. Пожалуйста, попробуйте снова.", + retry: "Повторить", + tip_amount: "Сумма", + tip_custom: "Своя", + tip_currency: "Валюта", + tip_private_key: "Активный ключ", + tip_wallet_address: "Адрес кошелька", + tip_no_wallet_address: "Получатель не настроил этот адрес кошелька.", + tip_send: "Отправить", + tip_sending: "Отправка...", + tip_login_to_send: "Войдите, чтобы отправить чаевые", + tip_asset_not_supported: "Этот актив пока не поддерживается для чаевых", + tip_transaction_failed: "Ошибка транзакции", + tip_qr_no_address: "Нет адреса", + tip_qr_failed: "Не удалось сгенерировать QR-код", + cancel: "Отмена", }, }; diff --git a/apps/self-hosted/src/features/auth/auth-actions.ts b/apps/self-hosted/src/features/auth/auth-actions.ts index 6b23dad4a2..8bb460cfd9 100644 --- a/apps/self-hosted/src/features/auth/auth-actions.ts +++ b/apps/self-hosted/src/features/auth/auth-actions.ts @@ -97,20 +97,28 @@ export function logout(): void { clearHiveAuthSession(); } +export type BroadcastAuthorityType = "Active" | "Posting" | "Owner" | "Memo"; + /** * Broadcast operations using the current user's auth method. * Throws if not authenticated or session/keychain is missing. + * @param authorityType - For keychain: which key to use (e.g. "Active" for transfers). */ -export async function broadcast(operations: Operation[]): Promise { +export async function broadcast( + operations: Operation[], + options?: { authorityType?: BroadcastAuthorityType } +): Promise { const { user, session } = authenticationStore.getState(); if (!user) { throw new Error("Not authenticated"); } + const authorityType = options?.authorityType ?? "Posting"; + switch (user.loginType) { case "keychain": - return keychainBroadcast(user.username, operations); + return keychainBroadcast(user.username, operations, authorityType); case "hivesigner": if (!user.accessToken) { diff --git a/apps/self-hosted/src/features/tipping/components/tipping-currency-cards.tsx b/apps/self-hosted/src/features/tipping/components/tipping-currency-cards.tsx index 9e6a5301ad..c5781c6955 100644 --- a/apps/self-hosted/src/features/tipping/components/tipping-currency-cards.tsx +++ b/apps/self-hosted/src/features/tipping/components/tipping-currency-cards.tsx @@ -30,6 +30,7 @@ export function TippingCurrencyCards({
{walletList?.map((asset) => ( onAssetSelect(asset)} diff --git a/apps/self-hosted/src/features/tipping/components/tipping-popover.tsx b/apps/self-hosted/src/features/tipping/components/tipping-popover.tsx index a4f0aa318c..1b7d5edf12 100644 --- a/apps/self-hosted/src/features/tipping/components/tipping-popover.tsx +++ b/apps/self-hosted/src/features/tipping/components/tipping-popover.tsx @@ -3,18 +3,10 @@ import { useCallback, useEffect, useState } from "react"; import { createPortal } from "react-dom"; import { useAuth } from "@/features/auth/hooks"; -import { - executeTip, - resolveFromAccountFromKey, - resolvePrivateKey, -} from "../utils/tip-transaction"; +import { executeTip } from "../utils/tip-transaction"; import { TippingStepAmount } from "./tipping-step-amount"; import { TippingStepCurrency } from "./tipping-step-currency"; -import { - ASSETS_REQUIRING_KEY, - isExternalWalletAsset, - type TippingAsset, -} from "../types"; +import { isExternalWalletAsset, type TippingAsset } from "../types"; interface TippingPopoverRefs { setReference: (element: HTMLElement | null) => void; @@ -51,7 +43,6 @@ export function TippingPopover({ const [selectedAsset, setSelectedAsset] = useState( undefined, ); - const [privateKeyStr, setPrivateKeyStr] = useState(""); const [loading, setLoading] = useState(false); const [error, setError] = useState(undefined); @@ -59,17 +50,11 @@ export function TippingPopover({ const hasValidAmount = Number.isFinite(amountNum) && amountNum > 0; const isExternalAsset = selectedAsset !== undefined && isExternalWalletAsset(selectedAsset); - const hasKeyInput = privateKeyStr.trim().length > 0; - const requiresKey = - selectedAsset !== undefined && - ASSETS_REQUIRING_KEY.includes( - selectedAsset as (typeof ASSETS_REQUIRING_KEY)[number], - ); const canSubmit = hasValidAmount && selectedAsset !== undefined && !isExternalAsset && - (!requiresKey || hasKeyInput) && + !!user?.username && !loading; const handleClickOutside = useCallback( @@ -110,7 +95,7 @@ export function TippingPopover({ }; const handleSubmit = useCallback(async () => { - if (!canSubmit) return; + if (!canSubmit || !user?.username) return; if ( selectedAsset !== "HIVE" && selectedAsset !== "HBD" && @@ -122,19 +107,11 @@ export function TippingPopover({ setLoading(true); setError(undefined); try { - const key = await resolvePrivateKey(privateKeyStr.trim(), user?.username); - let from: string; - if (user?.username) { - from = user.username; - } else { - from = await resolveFromAccountFromKey(key); - } await executeTip({ - from, + from: user.username, to, amount, asset: selectedAsset as TippingAsset, - key, memo, }); onClose(); @@ -143,16 +120,7 @@ export function TippingPopover({ } finally { setLoading(false); } - }, [ - canSubmit, - privateKeyStr, - user?.username, - to, - amount, - selectedAsset, - memo, - onClose, - ]); + }, [canSubmit, user?.username, to, amount, selectedAsset, memo, onClose]); const content = (
{ - setPrivateKeyStr(v); - setError(undefined); - }} error={error} canSubmit={canSubmit} loading={loading} diff --git a/apps/self-hosted/src/features/tipping/components/tipping-step-currency.tsx b/apps/self-hosted/src/features/tipping/components/tipping-step-currency.tsx index 57911684e2..fd56075687 100644 --- a/apps/self-hosted/src/features/tipping/components/tipping-step-currency.tsx +++ b/apps/self-hosted/src/features/tipping/components/tipping-step-currency.tsx @@ -1,12 +1,10 @@ import { t } from "@/core"; import { getAccountFullQueryOptions } from "@ecency/sdk"; import { useQuery } from "@tanstack/react-query"; -import { - isAssetRequiringKey, - isExternalWalletAsset, -} from "../types"; +import { isExternalWalletAsset } from "../types"; import { TippingCurrencyCards } from "./tipping-currency-cards"; import { TippingWalletQr } from "./tipping-wallet-qr"; +import { useAuth } from "@/features/auth"; interface TippingStepCurrencyProps { to: string; @@ -14,8 +12,6 @@ interface TippingStepCurrencyProps { onAmountChange: (value: string) => void; selectedAsset: string | undefined; onAssetSelect: (asset: string) => void; - privateKeyStr: string; - onPrivateKeyChange: (value: string) => void; error: string | undefined; canSubmit: boolean; loading: boolean; @@ -25,7 +21,6 @@ interface TippingStepCurrencyProps { const inputClassName = "w-full mb-3 px-3 py-2 rounded-md border border-theme bg-theme-primary text-theme-primary text-sm"; -const inputMonoClassName = `${inputClassName} font-mono`; function useRecipientWalletAddress(to: string, asset: string | undefined) { const enabled = !!to && !!asset && isExternalWalletAsset(asset); @@ -35,7 +30,7 @@ function useRecipientWalletAddress(to: string, asset: string | undefined) { }); if (!enabled || !account?.profile?.tokens) return undefined; const token = account.profile.tokens.find( - (t) => t.symbol?.toUpperCase() === asset?.toUpperCase(), + (token) => token.symbol?.toUpperCase() === asset?.toUpperCase(), ); const address = token?.meta?.address; return typeof address === "string" && address.trim() ? address : undefined; @@ -47,17 +42,16 @@ export function TippingStepCurrency({ onAmountChange, selectedAsset, onAssetSelect, - privateKeyStr, - onPrivateKeyChange, error, canSubmit, loading, onCancel, onSubmit, }: TippingStepCurrencyProps) { - const showKeyInput = - selectedAsset !== undefined && isAssetRequiringKey(selectedAsset); - const isExternal = selectedAsset !== undefined && isExternalWalletAsset(selectedAsset); + const { user } = useAuth(); + + const isExternal = + selectedAsset !== undefined && isExternalWalletAsset(selectedAsset); const recipientAddress = useRecipientWalletAddress(to, selectedAsset); return ( @@ -81,21 +75,6 @@ export function TippingStepCurrency({ selectedAsset={selectedAsset} onAssetSelect={onAssetSelect} /> - {showKeyInput && ( - <> -
- {t("tip_private_key")} -
- onPrivateKeyChange(e.target.value)} - className={inputMonoClassName} - /> - - )} {isExternal && ( <>
@@ -123,7 +102,7 @@ export function TippingStepCurrency({
)}
diff --git a/apps/self-hosted/src/features/tipping/components/tipping-wallet-qr.tsx b/apps/self-hosted/src/features/tipping/components/tipping-wallet-qr.tsx index 4497278fd6..3b36aba2dc 100644 --- a/apps/self-hosted/src/features/tipping/components/tipping-wallet-qr.tsx +++ b/apps/self-hosted/src/features/tipping/components/tipping-wallet-qr.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import QRCode from "qrcode"; +import { t } from "@/core"; interface TippingWalletQrProps { address: string; @@ -20,13 +21,15 @@ export function TippingWalletQr({ useEffect(() => { if (!address.trim()) { setDataUrl(null); - setError("No address"); + setError(t("tip_qr_no_address")); return; } setError(null); QRCode.toDataURL(address, { width: size, margin: 2 }) .then(setDataUrl) - .catch((err: unknown) => setError(err instanceof Error ? err.message : "Failed to generate QR")); + .catch((err: unknown) => + setError(err instanceof Error ? err.message : t("tip_qr_failed")), + ); }, [address, size]); if (error) { diff --git a/apps/self-hosted/src/features/tipping/types.ts b/apps/self-hosted/src/features/tipping/types.ts index efddfb8a3f..f20db149a9 100644 --- a/apps/self-hosted/src/features/tipping/types.ts +++ b/apps/self-hosted/src/features/tipping/types.ts @@ -2,6 +2,12 @@ export type TippingVariant = "post" | "general"; export type TippingAsset = "HIVE" | "HBD" | "POINTS"; +const TIPABLE_ASSETS: TippingAsset[] = ["HIVE", "HBD", "POINTS"]; + +export function isTipableAsset(asset: string): asset is TippingAsset { + return TIPABLE_ASSETS.includes(asset as TippingAsset); +} + /** Assets that require active key input for tipping */ export const ASSETS_REQUIRING_KEY = ["POINTS", "HIVE", "HBD", "HP"] as const; diff --git a/apps/self-hosted/src/features/tipping/utils/tip-transaction.ts b/apps/self-hosted/src/features/tipping/utils/tip-transaction.ts index fab05e6453..3306c83474 100644 --- a/apps/self-hosted/src/features/tipping/utils/tip-transaction.ts +++ b/apps/self-hosted/src/features/tipping/utils/tip-transaction.ts @@ -1,122 +1,87 @@ -import { CONFIG } from '@ecency/sdk'; -import { cryptoUtils, PrivateKey, type Operation } from '@hiveio/dhive'; -import type { TippingAsset } from '../types'; - -const KEY_TYPE = 'active' as const; - -function formatKeyError(err: unknown): string { - const msg = err instanceof Error ? err.message : String(err); - return /base58|invalid|wif/i.test(msg) - ? 'Invalid key format' - : 'Invalid private key or password'; -} - -/** - * Resolve raw key input to a PrivateKey. Same flow as apps/web KeyInput: - * - WIF (cryptoUtils.isWif) -> PrivateKey.fromString - * - With username: try master password (PrivateKey.fromLogin), then WIF - * - Without username: WIF only - * BIP44 seed support would require @ecency/wallets (detectHiveKeyDerivation, deriveHiveKeys). - */ -export async function resolvePrivateKey( - keyStr: string, - username?: string -): Promise { - const key = keyStr.trim(); - if (!key) { - throw new Error('Key is required'); - } - - try { - if (cryptoUtils.isWif(key)) { - return PrivateKey.fromString(key); - } - - if (username) { - try { - return PrivateKey.fromLogin(username, key, KEY_TYPE); - } catch { - return PrivateKey.fromString(key); - } - } - - return PrivateKey.fromString(key); - } catch (err) { - throw new Error(formatKeyError(err)); - } -} +import { broadcast } from "@/features/auth"; +import { getQueryClient } from "@ecency/sdk"; +import { getAccountWalletAssetInfoQueryOptions } from "@ecency/wallets"; +import type { Operation } from "@hiveio/dhive"; +import type { TippingAsset } from "../types"; +const ASSETS_WITH_USD_PRICE: TippingAsset[] = ["HIVE", "HBD"]; export interface ExecuteTipParams { from: string; to: string; amount: string; asset: TippingAsset; - key: PrivateKey; memo: string; } /** - * Resolve account name (from) from a private key using get_key_references. - */ -export async function resolveFromAccountFromKey(key: PrivateKey): Promise { - const publicKey = key.createPublic().toString(); - const result = (await CONFIG.hiveClient.database.call('get_key_references', [ - [publicKey], - ])) as string[][]; - const accounts = result?.[0]; - if (!accounts?.length) { - throw new Error('No account found for this key'); - } - return accounts[0]; -} - -/** - * Execute tip: transfer HIVE, HBD, or POINTS using CONFIG.hiveClient (no @ecency/wallets). + * Execute tip: transfer HIVE, HBD, or POINTS. + * Uses current auth (keychain or hivesigner) to broadcast. + * Fetches token price in USD via getTokenPriceQueryOptions for conversion. */ export async function executeTip(params: ExecuteTipParams): Promise { - const { from, to, amount, asset, key, memo } = params; + const { from, to, amount, asset, memo } = params; + + let realAmount = 0; const num = parseFloat(amount); if (!Number.isFinite(num) || num <= 0) { - throw new Error('Invalid amount'); + throw new Error("Invalid amount"); } const formatted = num.toFixed(3); - if (asset === 'HIVE') { - await CONFIG.hiveClient.broadcast.transfer( - { - from, - to, - amount: `${formatted} HIVE`, - memo, - }, - key - ); - } else if (asset === 'HBD') { - await CONFIG.hiveClient.broadcast.transfer( - { - from, - to, - amount: `${formatted} HBD`, - memo, - }, - key - ); - } else if (asset === 'POINTS') { - const op: Operation = [ - 'custom_json', - { - id: 'ecency_point_transfer', - json: JSON.stringify({ - sender: from, - receiver: to, - amount: `${formatted} POINT`, + + // Ensure USD price is loaded for supported assets (conversion relative to USD) + const queryClient = getQueryClient(); + + const info = await queryClient.ensureQueryData( + getAccountWalletAssetInfoQueryOptions(to, asset), + ); + realAmount = num / (info?.price ?? 0); + + let operations: Operation[]; + + if (asset === "HIVE") { + operations = [ + [ + "transfer", + { + from, + to, + amount: `${realAmount} HIVE`, + memo, + }, + ], + ]; + } else if (asset === "HBD") { + operations = [ + [ + "transfer", + { + from, + to, + amount: `${realAmount} HBD`, memo, - }), - required_auths: [from], - required_posting_auths: [], - }, + }, + ], + ]; + } else if (asset === "POINTS") { + operations = [ + [ + "custom_json", + { + id: "ecency_point_transfer", + json: JSON.stringify({ + sender: from, + receiver: to, + amount: `${realAmount} POINT`, + memo, + }), + required_auths: [from], + required_posting_auths: [], + }, + ], ]; - await CONFIG.hiveClient.broadcast.sendOperations([op], key); } else { throw new Error(`Unsupported asset: ${asset}`); } + + await broadcast(operations, { authorityType: "Active" }); }