From b6ef2428bf527d6d4048013f7f241b5c4b5abdf8 Mon Sep 17 00:00:00 2001 From: feruz Date: Tue, 31 Mar 2026 06:49:29 +0300 Subject: [PATCH 1/7] Thumbnail Waves and hivesigner fix --- .../_components/entry-page-body-viewer.tsx | 1 + .../_components/account-recovery.tsx | 4 +- .../_components/manage-authorities.tsx | 4 +- apps/web/src/app/auth/hs-callback/_page.tsx | 61 ++++++++++++++++ apps/web/src/app/auth/hs-callback/page.tsx | 15 ++++ .../content-viewer/deck-post-viewer.tsx | 2 +- .../deck-items/deck-thread-item-body.tsx | 2 +- .../_components/wave-view-details.tsx | 1 + .../app/waves/_components/waves-list-item.tsx | 1 + .../components/ecency-renderer.tsx | 4 +- .../three-speak-video-extension.tsx | 5 ++ .../components/utils/setupPostEnhancements.ts | 5 +- .../components/utils/threeSpeakThumbnail.ts | 73 +++++++++++++++++++ .../utils/threespeakVideosEnhancer.tsx | 5 +- .../discussion/discussion-item-body.tsx | 2 +- .../features/shared/post-content-renderer.tsx | 3 + .../transactions/transaction-signer.tsx | 3 +- .../shared/video-upload-threespeak/index.tsx | 39 ++++++---- .../operations/wallet-operations-sign.tsx | 7 +- apps/web/src/utils/hs-callback.ts | 17 +++++ packages/sdk/dist/browser/index.d.ts | 8 +- packages/sdk/dist/browser/index.js | 2 +- packages/sdk/dist/browser/index.js.map | 2 +- packages/sdk/dist/node/index.cjs | 2 +- packages/sdk/dist/node/index.cjs.map | 2 +- packages/sdk/dist/node/index.mjs | 2 +- packages/sdk/dist/node/index.mjs.map | 2 +- .../mutations/use-account-revoke-posting.ts | 10 +-- .../mutations/use-account-update-recovery.ts | 10 +-- 29 files changed, 250 insertions(+), 44 deletions(-) create mode 100644 apps/web/src/app/auth/hs-callback/_page.tsx create mode 100644 apps/web/src/app/auth/hs-callback/page.tsx create mode 100644 apps/web/src/features/post-renderer/components/utils/threeSpeakThumbnail.ts create mode 100644 apps/web/src/utils/hs-callback.ts diff --git a/apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-body-viewer.tsx b/apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-body-viewer.tsx index 36d9da5b7c..a6288e6a95 100644 --- a/apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-body-viewer.tsx +++ b/apps/web/src/app/(dynamicPages)/entry/[category]/[author]/[permlink]/_components/entry-page-body-viewer.tsx @@ -124,6 +124,7 @@ export function EntryPageBodyViewer({ entry }: Props) { setSigningOperation(op); }, TwitterComponent: Tweet, + images: entry.json_metadata?.image, }); } catch (e) { // Avoid breaking the page if enhancements fail, e.g. due to missing embeds or DOM structure issues diff --git a/apps/web/src/app/(dynamicPages)/profile/[username]/permissions/_components/account-recovery.tsx b/apps/web/src/app/(dynamicPages)/profile/[username]/permissions/_components/account-recovery.tsx index c7c69f55c0..ba4bda0cc6 100644 --- a/apps/web/src/app/(dynamicPages)/profile/[username]/permissions/_components/account-recovery.tsx +++ b/apps/web/src/app/(dynamicPages)/profile/[username]/permissions/_components/account-recovery.tsx @@ -10,6 +10,7 @@ import { useAccountUpdateRecovery } from "@ecency/sdk"; import { PrivateKey } from "@hiveio/dhive"; +import { buildHsCallbackUrl } from "@/utils/hs-callback"; import { yupResolver } from "@hookform/resolvers/yup"; import { useQuery } from "@tanstack/react-query"; import { UilEditAlt } from "@tooni/iconscout-unicons-react"; @@ -70,7 +71,8 @@ export function AccountRecovery() { getAccessToken(activeUser?.username ?? ""), { onError: (e) => error(...formatError(e)), - onSuccess: () => success(i18next.t("account-recovery.success-message")) + onSuccess: () => success(i18next.t("account-recovery.success-message")), + hsCallbackUrl: buildHsCallbackUrl(`/@${activeUser?.username}/permissions`) }, getSdkAuthContext(getUser(activeUser?.username ?? "")) ); diff --git a/apps/web/src/app/(dynamicPages)/profile/[username]/permissions/_components/manage-authorities.tsx b/apps/web/src/app/(dynamicPages)/profile/[username]/permissions/_components/manage-authorities.tsx index 705606f363..121e6a4d2f 100644 --- a/apps/web/src/app/(dynamicPages)/profile/[username]/permissions/_components/manage-authorities.tsx +++ b/apps/web/src/app/(dynamicPages)/profile/[username]/permissions/_components/manage-authorities.tsx @@ -6,6 +6,7 @@ import { ProfilePreview } from "@/features/shared/profile-popover/profile-previe import { Popover } from "@/features/ui"; import { getAccountFullQueryOptions, useAccountRevokePosting } from "@ecency/sdk"; import { useQuery } from "@tanstack/react-query"; +import { buildHsCallbackUrl } from "@/utils/hs-callback"; import { Button } from "@ui/button"; import { Modal, ModalBody, ModalHeader } from "@ui/modal"; import i18next from "i18next"; @@ -35,7 +36,8 @@ export function ManageAuthorities() { activeUser?.username, { onError: (err) => error((err as Error).message), - onSuccess: () => setKeyDialog(false) + onSuccess: () => setKeyDialog(false), + hsCallbackUrl: buildHsCallbackUrl(`/@${activeUser?.username}/permissions`) }, getSdkAuthContext(getUser(activeUser?.username ?? "")) ); diff --git a/apps/web/src/app/auth/hs-callback/_page.tsx b/apps/web/src/app/auth/hs-callback/_page.tsx new file mode 100644 index 0000000000..1923118073 --- /dev/null +++ b/apps/web/src/app/auth/hs-callback/_page.tsx @@ -0,0 +1,61 @@ +"use client"; + +import { UilCheckCircle, UilTimesCircle } from "@tooni/iconscout-unicons-react"; +import i18next from "i18next"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect, useState } from "react"; + +export function HsCallbackPage() { + const params = useSearchParams(); + const router = useRouter(); + + const txId = params?.get("id") ?? ""; + const block = params?.get("block") ?? ""; + const redirect = params?.get("redirect") ?? "/"; + + const isSuccess = txId.length > 0 || block.length > 0; + const [countdown, setCountdown] = useState(3); + + useEffect(() => { + if (countdown <= 0) { + router.push(redirect); + return; + } + const timer = setTimeout(() => setCountdown((c) => c - 1), 1000); + return () => clearTimeout(timer); + }, [countdown, redirect, router]); + + return ( +
+
+ {isSuccess ? ( + <> + +

+ {i18next.t("g.success")} +

+

+ {i18next.t("transactions.success-hint")} +

+ {txId && ( + {txId} + )} + + ) : ( + <> + +

+ {i18next.t("g.error")} +

+

+ {i18next.t("transactions.error-hint")} +

+ + )} +

+ {i18next.t("g.redirecting-in", { defaultValue: `Redirecting in ${countdown}s...`, n: countdown })} +

+
+
+ ); +} diff --git a/apps/web/src/app/auth/hs-callback/page.tsx b/apps/web/src/app/auth/hs-callback/page.tsx new file mode 100644 index 0000000000..6f1921030b --- /dev/null +++ b/apps/web/src/app/auth/hs-callback/page.tsx @@ -0,0 +1,15 @@ +import { Navbar } from "@/features/shared/navbar"; +import { Theme } from "@/features/shared/theme"; +import { HsCallbackPage } from "./_page"; + +export const dynamic = "force-dynamic"; + +export default function HsCallback() { + return ( + <> + + + + + ); +} diff --git a/apps/web/src/app/decks/_components/columns/content-viewer/deck-post-viewer.tsx b/apps/web/src/app/decks/_components/columns/content-viewer/deck-post-viewer.tsx index cbf8fff0c1..04dae9c3c5 100644 --- a/apps/web/src/app/decks/_components/columns/content-viewer/deck-post-viewer.tsx +++ b/apps/web/src/app/decks/_components/columns/content-viewer/deck-post-viewer.tsx @@ -64,7 +64,7 @@ export const DeckPostViewer = ({ entry: initialEntry, onClose, backTitle }: Prop
- +
diff --git a/apps/web/src/app/decks/_components/columns/deck-items/deck-thread-item-body.tsx b/apps/web/src/app/decks/_components/columns/deck-items/deck-thread-item-body.tsx index 117a2de878..43f4ffc285 100644 --- a/apps/web/src/app/decks/_components/columns/deck-items/deck-thread-item-body.tsx +++ b/apps/web/src/app/decks/_components/columns/deck-items/deck-thread-item-body.tsx @@ -38,7 +38,7 @@ export const DeckThreadItemBody = ({ return (
- + {currentViewingImage && portalContainer && createPortal( diff --git a/apps/web/src/app/waves/[author]/[permlink]/_components/wave-view-details.tsx b/apps/web/src/app/waves/[author]/[permlink]/_components/wave-view-details.tsx index e8c8fbcaa4..b63fa98795 100644 --- a/apps/web/src/app/waves/[author]/[permlink]/_components/wave-view-details.tsx +++ b/apps/web/src/app/waves/[author]/[permlink]/_components/wave-view-details.tsx @@ -65,6 +65,7 @@ export function WaveViewDetails({ entry: initialEntry }: Props) {
{poll && } diff --git a/apps/web/src/app/waves/_components/waves-list-item.tsx b/apps/web/src/app/waves/_components/waves-list-item.tsx index e2d1b043bd..aef52c7403 100644 --- a/apps/web/src/app/waves/_components/waves-list-item.tsx +++ b/apps/web/src/app/waves/_components/waves-list-item.tsx @@ -301,6 +301,7 @@ export const WavesListItem = React.memo(function WavesListItem({ ) : ( )} diff --git a/apps/web/src/features/post-renderer/components/ecency-renderer.tsx b/apps/web/src/features/post-renderer/components/ecency-renderer.tsx index 4a26ed7525..74e1203c2e 100644 --- a/apps/web/src/features/post-renderer/components/ecency-renderer.tsx +++ b/apps/web/src/features/post-renderer/components/ecency-renderer.tsx @@ -23,6 +23,7 @@ interface Props { seoContext?: SeoContext; onHiveOperationClick?: (op: string) => void; TwitterComponent?: any; + images?: string[]; } export function EcencyRenderer({ @@ -31,6 +32,7 @@ export function EcencyRenderer({ seoContext, onHiveOperationClick, TwitterComponent = () =>
No twitter component
, + images, ...other }: HTMLProps & Props) { const ref = useRef(null); @@ -55,7 +57,7 @@ export function EcencyRenderer({ - + ; + images?: string[]; }) { const rootsRef = useRef[]>([]); @@ -109,6 +112,8 @@ export function ThreeSpeakVideoExtension({ return; } + injectThreeSpeakThumbnail(element, images); + const container = document.createElement("div"); container.classList.add("ecency-renderer-speak-extension-frame"); diff --git a/apps/web/src/features/post-renderer/components/utils/setupPostEnhancements.ts b/apps/web/src/features/post-renderer/components/utils/setupPostEnhancements.ts index 4fff7577af..a13c86635f 100644 --- a/apps/web/src/features/post-renderer/components/utils/setupPostEnhancements.ts +++ b/apps/web/src/features/post-renderer/components/utils/setupPostEnhancements.ts @@ -33,7 +33,8 @@ const TwitterFallback: React.FC<{ id: string }> = ({ id }) => { */ export function setupPostEnhancements(container: HTMLElement, options?: { onHiveOperationClick?: (op: string) => void, - TwitterComponent?: any + TwitterComponent?: any, + images?: string[] }): () => void { const postLinkElements = findPostLinkElements(container); @@ -42,7 +43,7 @@ export function setupPostEnhancements(container: HTMLElement, options?: { ...applyAuthorLinks(container), ...applyHiveOperations(container, options?.onHiveOperationClick), ...applyYoutubeVideos(container), - ...applyThreeSpeakVideos(container), + ...applyThreeSpeakVideos(container, options?.images), ...applyWaveLikePosts(container, postLinkElements), ...applyTwitterEmbeds(container, options?.TwitterComponent ?? TwitterFallback) ]; diff --git a/apps/web/src/features/post-renderer/components/utils/threeSpeakThumbnail.ts b/apps/web/src/features/post-renderer/components/utils/threeSpeakThumbnail.ts new file mode 100644 index 0000000000..0c8eaedf1e --- /dev/null +++ b/apps/web/src/features/post-renderer/components/utils/threeSpeakThumbnail.ts @@ -0,0 +1,73 @@ +/** + * Injects a thumbnail image into a 3Speak video embed element + * if one doesn't already exist. + * + * Looks through the post's json_metadata.image array for a suitable + * thumbnail, preferring images that reference the same video ID or + * come from known 3Speak CDN domains. + */ +export function injectThreeSpeakThumbnail( + element: HTMLElement, + images?: string[] +): void { + if (!images?.length) return; + if (element.querySelector(".video-thumbnail")) return; + + const embedSrc = element.dataset.embedSrc ?? ""; + const videoId = extractVideoId(embedSrc); + + const thumbnail = findBestThumbnail(images, videoId); + if (!thumbnail) return; + + const img = document.createElement("img"); + img.className = "no-replace video-thumbnail"; + img.setAttribute("itemprop", "thumbnailUrl"); + img.src = thumbnail; + + const playBtn = element.querySelector(".markdown-video-play"); + if (playBtn) { + element.insertBefore(img, playBtn); + } else { + element.appendChild(img); + } +} + +function extractVideoId(embedSrc: string): string { + try { + const url = new URL(embedSrc); + return url.searchParams.get("v") ?? ""; + } catch { + return ""; + } +} + +const THREESPEAK_CDN_PATTERNS = [ + "3speakcontent", + "threespeakvideo", + "3speak.tv", + "3speak.co", +]; + +function findBestThumbnail( + images: string[], + videoId: string +): string | undefined { + if (!images.length) return undefined; + + const permlink = videoId.split("/")[1] ?? ""; + + // Prefer image that matches the video permlink + if (permlink) { + const match = images.find((img) => img.includes(permlink)); + if (match) return match; + } + + // Then prefer images from 3Speak CDN + const cdnMatch = images.find((img) => + THREESPEAK_CDN_PATTERNS.some((pattern) => img.includes(pattern)) + ); + if (cdnMatch) return cdnMatch; + + // Fall back to first image (likely the post thumbnail set during publish) + return images[0]; +} diff --git a/apps/web/src/features/post-renderer/components/utils/threespeakVideosEnhancer.tsx b/apps/web/src/features/post-renderer/components/utils/threespeakVideosEnhancer.tsx index 888f4c8841..dfdf7e0e11 100644 --- a/apps/web/src/features/post-renderer/components/utils/threespeakVideosEnhancer.tsx +++ b/apps/web/src/features/post-renderer/components/utils/threespeakVideosEnhancer.tsx @@ -1,10 +1,11 @@ import { createRoot, Root } from "react-dom/client"; import { ThreeSpeakVideoRenderer } from "../extensions"; +import { injectThreeSpeakThumbnail } from "./threeSpeakThumbnail"; /** * DOM utility enhancer */ -export function applyThreeSpeakVideos(container: HTMLElement): Root[] { +export function applyThreeSpeakVideos(container: HTMLElement, images?: string[]): Root[] { const roots: Root[] = []; const elements = Array.from( container.querySelectorAll( @@ -16,6 +17,8 @@ export function applyThreeSpeakVideos(container: HTMLElement): Root[] { if (el.dataset.enhanced === "true") return; el.dataset.enhanced = "true"; + injectThreeSpeakThumbnail(el, images); + const embedSrc = el.dataset.embedSrc ?? ""; const wrapper = document.createElement("div"); wrapper.classList.add("ecency-renderer-speak-extension-frame"); diff --git a/apps/web/src/features/shared/discussion/discussion-item-body.tsx b/apps/web/src/features/shared/discussion/discussion-item-body.tsx index 619c772adb..ff82a53bc6 100644 --- a/apps/web/src/features/shared/discussion/discussion-item-body.tsx +++ b/apps/web/src/features/shared/discussion/discussion-item-body.tsx @@ -12,7 +12,7 @@ export function DiscussionItemBody({ entry, isRawContent }: Props) { return ( <> {!isRawContent ? ( - + ) : (
{entry.body}
)} diff --git a/apps/web/src/features/shared/post-content-renderer.tsx b/apps/web/src/features/shared/post-content-renderer.tsx index afc2c2f62a..f0743b1853 100644 --- a/apps/web/src/features/shared/post-content-renderer.tsx +++ b/apps/web/src/features/shared/post-content-renderer.tsx @@ -12,12 +12,14 @@ interface Props { value: string; seoContext?: SeoContext; onTagClick?: (tag: string) => void; + images?: string[]; } export function PostContentRenderer({ value, seoContext, onTagClick, + images, ...props }: Props & Omit, "value">) { const [signingOperation, setSigningOperation] = useState(); @@ -74,6 +76,7 @@ export function PostContentRenderer({ ("details"); diff --git a/apps/web/src/features/shared/video-upload-threespeak/index.tsx b/apps/web/src/features/shared/video-upload-threespeak/index.tsx index 47de0025a3..215cb26519 100644 --- a/apps/web/src/features/shared/video-upload-threespeak/index.tsx +++ b/apps/web/src/features/shared/video-upload-threespeak/index.tsx @@ -117,9 +117,15 @@ export const VideoUpload = (props: Props & React.HTMLAttributes) const extractFrameAsBlob = useCallback( (video: HTMLVideoElement): Promise => new Promise((resolve) => { + const w = video.videoWidth; + const h = video.videoHeight; + if (!w || !h) { + resolve(null); + return; + } const canvas = document.createElement("canvas"); - canvas.width = video.videoWidth; - canvas.height = video.videoHeight; + canvas.width = w; + canvas.height = h; const ctx = canvas.getContext("2d"); if (!ctx) { canvas.width = 0; @@ -127,7 +133,7 @@ export const VideoUpload = (props: Props & React.HTMLAttributes) resolve(null); return; } - ctx.drawImage(video, 0, 0, canvas.width, canvas.height); + ctx.drawImage(video, 0, 0, w, h); canvas.toBlob( (blob) => { // Release canvas GPU resources @@ -151,33 +157,37 @@ export const VideoUpload = (props: Props & React.HTMLAttributes) const video = videoRef.current; if (!video) return; - let cancelled = false; + const abortController = new AbortController(); const permlink = videoData.permlink; + const timeout = setTimeout(() => abortController.abort(), 15_000); + const handleSeeked = async () => { - if (cancelled) return; + if (abortController.signal.aborted) return; setIsExtractingThumbnail(true); try { const blob = await extractFrameAsBlob(video); - if (cancelled || !blob) return; + if (abortController.signal.aborted || !blob) return; const file = new File([blob], "auto-thumbnail.jpg", { type: "image/jpeg" }); - const response = await uploadThumbnailImage({ file }); - if (cancelled || !response?.url) return; + const response = await uploadThumbnailImage({ file, signal: abortController.signal }); + if (abortController.signal.aborted || !response?.url) return; setThumbnailUrl(response.url); try { await setVideoThumbnail(permlink, response.url); } catch { - // Thumbnail image uploaded successfully but metadata persistence failed — non-critical + // Thumbnail image uploaded successfully but metadata persistence failed - non-critical } + } catch { + // Extraction or upload failed/aborted - non-critical, user can still insert video } finally { - if (!cancelled) setIsExtractingThumbnail(false); + if (!abortController.signal.aborted) setIsExtractingThumbnail(false); } }; const handleLoaded = () => { - if (cancelled) return; + if (abortController.signal.aborted) return; video.currentTime = Math.max(0.1, Math.min(1, video.duration * 0.25)); }; @@ -190,7 +200,9 @@ export const VideoUpload = (props: Props & React.HTMLAttributes) } return () => { - cancelled = true; + clearTimeout(timeout); + abortController.abort(); + setIsExtractingThumbnail(false); video.removeEventListener("seeked", handleSeeked); video.removeEventListener("loadeddata", handleLoaded); }; @@ -320,8 +332,7 @@ export const VideoUpload = (props: Props & React.HTMLAttributes)