Merge pull request #725 from plebbit/development

Development
This commit is contained in:
plebeius
2025-07-16 22:56:28 +02:00
committed by GitHub
14 changed files with 284 additions and 44 deletions

View File

@@ -20,22 +20,19 @@ jobs:
with:
# needed for git commit history changelog
fetch-depth: 0
- name: Setup Node.js v20
uses: actions/setup-node@v2
with:
node-version: 20
- name: Install dependencies (with Node v20)
run: yarn install --frozen-lockfile --ignore-engines
# make sure the ipfs executable is executable
- name: Download IPFS and set permissions (with Node v20)
run: node electron/download-ipfs && sudo chmod +x bin/linux/ipfs
- name: Setup Node.js v22 for Electron build
- name: Setup Node.js v22
uses: actions/setup-node@v2
with:
node-version: 22
- name: Verify Node.js version consistency
run: yarn verify-node
- name: Install dependencies (with Node v22)
run: yarn install --frozen-lockfile --ignore-engines
# make sure the ipfs executable is executable
- name: Download IPFS and set permissions (with Node v22)
run: node electron/download-ipfs && sudo chmod +x bin/linux/ipfs
- name: Build React app (with Node v22)
run: CI='' yarn build
run: CI='' NODE_ENV=production yarn build
- name: Build Electron app for Linux (with Node v22)
run: yarn electron:build:linux
- name: List dist directory
@@ -62,27 +59,24 @@ jobs:
with:
# needed for git commit history changelog
fetch-depth: 0
- name: Setup Node.js v20
- name: Setup Node.js v22
uses: actions/setup-node@v2
with:
node-version: 20
node-version: 22
# install missing dep for sqlite
- run: python3 -m ensurepip
- run: pip install setuptools
- name: Install dependencies (with Node v20)
- name: Verify Node.js version consistency
run: yarn verify-node
- name: Install dependencies (with Node v22)
run: yarn install --frozen-lockfile --ignore-engines
# make sure the ipfs executable is executable
- name: Download IPFS and set permissions (with Node v20)
- name: Download IPFS and set permissions (with Node v22)
run: node electron/download-ipfs && sudo chmod +x bin/mac/ipfs
- name: Setup Node.js v22 for Electron build
uses: actions/setup-node@v2
with:
node-version: 22
- name: Build React app (with Node v22)
run: CI='' yarn build
run: CI='' NODE_ENV=production yarn build
- name: Build Electron app for Mac (with Node v22)
run: yarn electron:build:mac
- name: List dist directory
@@ -113,10 +107,12 @@ jobs:
uses: actions/setup-node@v2
with:
node-version: 22
- name: Verify Node.js version consistency
run: yarn verify-node
- name: Install dependencies (with Node v22) # --network-timeout and --network-concurrency are yarn v1 flags.
run: yarn install --frozen-lockfile --network-timeout 100000 --network-concurrency 1
- name: Build React app (with Node v22)
run: yarn build
run: npx cross-env NODE_ENV=production yarn build
- name: Build Electron app for Windows (with Node v22)
run: yarn electron:build:windows
- name: List dist directory
@@ -151,14 +147,16 @@ jobs:
gradle-version: 8.9
- uses: actions/setup-node@v2
with:
node-version: 20
node-version: 22
- name: Verify Node.js version consistency
run: yarn verify-node
- run: sudo apt install -y apksigner zipalign
# install all dependencies (including devDependencies needed for React build)
- name: Install dependencies (with Node v20)
- name: Install dependencies (with Node v22)
run: yarn install --frozen-lockfile --ignore-engines
# build react app
- run: CI='' yarn build
- run: CI='' NODE_ENV=production yarn build
# set android versionCode and versionName
- run: sed -i "s/versionCode 1/versionCode $(git tag | wc -l)/" ./android/app/build.gradle
- run: sed -i "s/versionName \"1.0\"/versionName \"$(node -e "console.log(require('./package.json').version)")\"/" ./android/app/build.gradle

View File

@@ -59,12 +59,12 @@
},
"scripts": {
"start": "vite",
"build": "cross-env PUBLIC_URL=./ GENERATE_SOURCEMAP=false vite build",
"build:preload": "vite build --config electron/vite.preload.config.js",
"build-netlify": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" PUBLIC_URL=./ GENERATE_SOURCEMAP=true VITE_COMMIT_REF=$COMMIT_REF CI='' vite build",
"build": "cross-env NODE_ENV=production PUBLIC_URL=./ GENERATE_SOURCEMAP=false vite build",
"build:preload": "cross-env NODE_ENV=production vite build --config electron/vite.preload.config.js",
"build-netlify": "cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" NODE_ENV=production PUBLIC_URL=./ GENERATE_SOURCEMAP=true VITE_COMMIT_REF=$COMMIT_REF CI='' vite build",
"test": "vitest",
"preview": "vite preview",
"analyze-bundle": "cross-env PUBLIC_URL=./ GENERATE_SOURCEMAP=true vite build && npx source-map-explorer 'build/assets/*.js'",
"analyze-bundle": "cross-env NODE_ENV=production PUBLIC_URL=./ GENERATE_SOURCEMAP=true vite build && npx source-map-explorer 'build/assets/*.js'",
"electron": "yarn build:preload && cross-env ELECTRON_IS_DEV=1 yarn electron:before && cross-env ELECTRON_IS_DEV=1 electron .",
"electron:no-delete-data": "yarn electron:before:download-ipfs && electron .",
"electron:start": "concurrently \"cross-env BROWSER=none yarn start\" \"wait-on http://localhost:3000 && yarn electron\"",

View File

@@ -0,0 +1 @@
export { default } from './info-tooltip';

View File

@@ -0,0 +1,78 @@
.tooltipIcon {
cursor: help;
margin-left: 0.5em;
}
.tooltip {
/* Just handles positioning from floating-ui */
z-index: 100;
/* Default triangle position as fallback */
--triangle-left-border: 8px;
--triangle-left-fill: 9px;
}
.tooltipInner {
/* Enhanced animation with both opacity and transform */
animation: tooltipFadeIn 0.1s ease-out forwards;
/* Move all visual styles here so they animate together */
background: var(--background);
color: var(--info-tooltip-text);
border: 1px solid var(--info-tooltip-border-color);
padding: 3px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25);
}
.tooltip.exiting .tooltipInner {
animation: tooltipFadeOut 0.1s ease-in forwards;
}
/* Fade in animation: slides down from above while fading in */
@keyframes tooltipFadeIn {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Fade out animation: slides up while fading out */
@keyframes tooltipFadeOut {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-6px);
}
}
.tooltipContent {
white-space: pre-wrap;
font: 10px verdana, arial, helvetica, sans-serif;
color: var(--info-tooltip-text);
margin: .5em;
}
.tooltipInner::before {
content: '';
position: absolute;
top: -20px;
left: var(--triangle-left-border);
border: 10px solid transparent;
border-bottom-color: var(--info-tooltip-border-color);
z-index: 1;
}
.tooltipInner::after {
content: '';
position: absolute;
top: -18px;
left: var(--triangle-left-fill);
border: 9px solid transparent;
border-bottom-color: var(--background);
z-index: 2;
}

View File

@@ -0,0 +1,117 @@
import { useState, useLayoutEffect, useRef } from 'react';
import { useFloating, autoUpdate, offset, shift, size, useHover, useFocus, useDismiss, useRole, useInteractions, FloatingPortal, safePolygon } from '@floating-ui/react';
import styles from './info-tooltip.module.css';
interface InfoTooltipProps {
content: string;
showTooltip?: boolean;
}
const InfoTooltip = ({ content, showTooltip = true }: InfoTooltipProps) => {
const [isOpen, setIsOpen] = useState(false);
const [isVisible, setIsVisible] = useState(false);
const [isExiting, setIsExiting] = useState(false);
const exitTimeoutRef = useRef<NodeJS.Timeout>();
// Handle opening and closing with animations
const handleOpenChange = (open: boolean) => {
if (open) {
// Opening: show immediately and set open state
setIsVisible(true);
setIsOpen(true);
setIsExiting(false);
// Clear any pending exit timeout
if (exitTimeoutRef.current) {
clearTimeout(exitTimeoutRef.current);
exitTimeoutRef.current = undefined;
}
} else {
// Closing: start exit animation, then hide after animation completes
setIsOpen(false);
setIsExiting(true);
// Remove from DOM after exit animation completes (200ms)
exitTimeoutRef.current = setTimeout(() => {
setIsVisible(false);
setIsExiting(false);
}, 200);
}
};
const { refs, floatingStyles, context } = useFloating({
open: isOpen,
onOpenChange: handleOpenChange,
placement: 'bottom-start',
whileElementsMounted: autoUpdate,
middleware: [
offset({ mainAxis: 12 }), // 5px lower (7+5=12), 10px to the left (-10)
shift(),
size({
apply({ availableWidth, elements }) {
Object.assign(elements.floating.style, {
maxWidth: `${Math.min(availableWidth, 460)}px`, // 35em ≈ 560px with 20px right padding
});
},
}),
],
});
// Calculate triangle position dynamically
useLayoutEffect(() => {
if (isOpen && refs.reference.current && refs.floating.current) {
const referenceRect = refs.reference.current.getBoundingClientRect();
const floatingRect = refs.floating.current.getBoundingClientRect();
// Calculate the horizontal center of the reference element relative to the floating element
const referenceCenterX = referenceRect.left + referenceRect.width / 2;
const floatingLeftX = floatingRect.left;
const triangleLeft = referenceCenterX - floatingLeftX;
// Set CSS custom properties for triangle positioning
// Keep the 1px offset between border and fill triangles for proper border effect
refs.floating.current.style.setProperty('--triangle-left-border', `${Math.max(0, triangleLeft - 2)}px`);
refs.floating.current.style.setProperty('--triangle-left-fill', `${Math.max(1, triangleLeft - 1)}px`);
}
}, [isOpen, refs.reference, refs.floating, floatingStyles]);
const hover = useHover(context, {
move: false,
delay: { open: 200, close: 1000 },
handleClose: safePolygon(),
});
const focus = useFocus(context);
const dismiss = useDismiss(context);
const role = useRole(context, { role: 'tooltip' });
const { getReferenceProps, getFloatingProps } = useInteractions([hover, focus, dismiss, role]);
// Clean up timeout on unmount
useLayoutEffect(() => {
return () => {
if (exitTimeoutRef.current) {
clearTimeout(exitTimeoutRef.current);
}
};
}, []);
return (
<>
<sup className={styles.tooltipIcon} ref={refs.setReference} {...getReferenceProps()}>
[?]
</sup>
{showTooltip && (
<FloatingPortal>
{isVisible && (
<div className={`${styles.tooltip} ${isExiting ? styles.exiting : ''}`} ref={refs.setFloating} style={floatingStyles} {...getFloatingProps()}>
<div className={styles.tooltipInner}>
<p className={styles.tooltipContent}>{content}</p>
</div>
</div>
)}
</FloatingPortal>
)}
</>
);
};
export default InfoTooltip;

View File

@@ -54,7 +54,9 @@ const ModeratorsList = ({ roles }: { roles: Record<string, Role> }) => {
<div className={styles.listTitle}>{t('moderators')}</div>
<ul className={`${styles.listContent} ${styles.modsList}`}>
{rolesList.map(({ address }, index) => (
<li key={index}>u/{Plebbit.getShortAddress(address)}</li>
<li key={index} onClick={() => window.alert('Direct profile links are not supported yet.')}>
u/{Plebbit.getShortAddress(address)}
</li>
))}
{/* TODO: https://github.com/plebbit/seedit/issues/274
<li className={styles.listMore}>{t('about_moderation')} »</li> */}

View File

@@ -16,11 +16,19 @@ if (window.location.hostname.startsWith('p2p.')) {
};
}
const reloadSW = registerSW({
registerSW({
immediate: true,
onNeedRefresh() {
// Reload the page to load the new version
reloadSW(true);
// Use window.location.reload() as it's more reliable than reloadSW(true)
if (!sessionStorage.getItem('sw-update-reload')) {
sessionStorage.setItem('sw-update-reload', 'true');
window.location.reload();
}
},
onOfflineReady() {
// Clear the reload flag when offline-ready (prevents loops)
sessionStorage.removeItem('sw-update-reload');
},
});

View File

@@ -7,6 +7,12 @@ export const isUserOwner = (roles: Roles | undefined, userAddress: string | unde
return roles[userAddress]?.role === 'owner';
};
export const isUserOwnerOrAdmin = (roles: Roles | undefined, userAddress: string | undefined): boolean => {
if (!roles || !userAddress) return false;
const userRole = roles[userAddress]?.role;
return userRole === 'owner' || userRole === 'admin';
};
export const findSubplebbitCreator = (roles: Roles | undefined): string => {
if (!roles) {
return 'anonymous';

View File

@@ -51,6 +51,8 @@
--green: #228822;
--green-bright: rgb(76, 207, 92);
--icon: #c6c6c6;
--info-tooltip-text: #bfbfbf;
--info-tooltip-border-color: #3e3e3e;
--link: #bfbfbf;
--link-primary: rgb(125, 175, 216);
--link-visited: #757575;
@@ -151,6 +153,8 @@
--green: #228822;
--green-bright: #3bc54c;
--icon: #c6c6c6;
--info-tooltip-text: #333;
--info-tooltip-border-color: gray;
--link: #0000ff;
--link-primary: #369;
--link-visited: #551a8b;

View File

@@ -214,6 +214,11 @@ const Mod = () => {
</div>
) : (
<div className={styles.feed}>
{process.env.NODE_ENV !== 'production' && (
<button className={styles.debugButton} onClick={reset}>
Reset Feed
</button>
)}
<Virtuoso
increaseViewportBy={{ bottom: 1200, top: 600 }}
totalCount={feed?.length || 0}

View File

@@ -2,6 +2,7 @@ import { Fragment, useState } from 'react';
import { Account, setAccount, useAccount } from '@plebbit/plebbit-react-hooks';
import styles from './wallet-settings.module.css';
import { Trans, useTranslation } from 'react-i18next';
import InfoTooltip from '../../../components/info-tooltip';
interface Wallet {
chainTicker: string;
@@ -184,6 +185,9 @@ const CryptoWalletsForm = ({ account }: { account: Account | undefined }) => {
i18nKey='add_wallet'
components={{ 1: <button key={`addWalletButton-${walletsArray.length}`} onClick={() => setWalletsArray([...walletsArray, defaultWalletObject])} /> }}
/>
<InfoTooltip
content={`Link crypto wallets to your account by proving ownership through signed messages. Communities can verify your token/NFT holdings to grant posting privileges or special access.`}
/>
</div>
{walletsInputs}
</>

View File

@@ -16,6 +16,7 @@ import Markdown from '../../components/markdown';
import Embed from '../../components/post/embed';
import { FormattingHelpTable } from '../../components/reply-form/reply-form';
import styles from './submit-page.module.css';
import InfoTooltip from '../../components/info-tooltip';
const isAndroid = Capacitor.getPlatform() === 'android';
const isElectron = window.electronApi?.isElectron === true;
@@ -75,7 +76,12 @@ const UrlField = () => {
{url && isValidURL(url) ? (
<div className={styles.mediaPreview}>{mediaError ? <span className={styles.mediaError}>{t('no_media_found')}</span> : mediaComponent}</div>
) : (
<div className={styles.description}>{t('submit_url_description')}</div>
<div className={styles.description}>
{t('submit_url_description')}
<InfoTooltip
content={`Seedit also supports links from the following sites: YouTube, Twitter/X, Reddit, Twitch, TikTok, Instagram, Odysee, Bitchute, Streamable, Spotify, and SoundCloud.`}
/>
</div>
)}
</div>
</div>

View File

@@ -11,7 +11,7 @@ import {
useSubplebbit,
useSubscribe,
} from '@plebbit/plebbit-react-hooks';
import { isUserOwner, Roles } from '../../lib/utils/user-utils';
import { isUserOwnerOrAdmin, Roles } from '../../lib/utils/user-utils';
import { isValidURL } from '../../lib/utils/url-utils';
import { isCreateSubplebbitView, isSubplebbitSettingsView } from '../../lib/utils/view-utils';
import useSubplebbitSettingsStore from '../../stores/use-subplebbit-settings-store';
@@ -374,7 +374,14 @@ const SubplebbitSettings = () => {
navigate('/', { replace: true });
}
const isReadOnly = (!settings && isInSubplebbitSettingsView) || (!isConnectedToRpc && isInCreateSubplebbitView);
const userAddress = account?.author?.address;
const userIsOwnerOrAdmin = isUserOwnerOrAdmin(roles, userAddress);
// General fields can be edited by owners/admins even without RPC connection
const isReadOnly = (!settings && isInSubplebbitSettingsView && !userIsOwnerOrAdmin) || (!isConnectedToRpc && isInCreateSubplebbitView && !userIsOwnerOrAdmin);
// Challenges are always read-only when not connected to RPC
const isChallengesReadOnly = !isConnectedToRpc;
const { publishSubplebbitEditOptions, resetSubplebbitSettingsStore, setSubplebbitSettingsStore, title: storeTitle } = useSubplebbitSettingsStore();
const { error: publishSubplebbitEditError, publishSubplebbitEdit } = usePublishSubplebbitEdit(publishSubplebbitEditOptions);
@@ -551,8 +558,6 @@ const SubplebbitSettings = () => {
window.scrollTo(0, 0);
}, []);
const userAddress = account?.author?.address;
const userIsOwner = isUserOwner(roles, userAddress);
const loadingStateString = useStateString(subplebbit);
if (!hasLoaded && !isInCreateSubplebbitView) {
@@ -577,14 +582,17 @@ const SubplebbitSettings = () => {
<Sidebar subplebbit={subplebbit} />
</div>
)}
{isReadOnly && !userIsOwner && <div className={styles.infobar}>{t('owner_settings_notice')}</div>}
{isReadOnly && !userIsOwnerOrAdmin && <div className={styles.infobar}>{t('owner_settings_notice')}</div>}
{!isReadOnly && userIsOwnerOrAdmin && !isConnectedToRpc && (
<div className={styles.infobar}>editing anti-spam challenges requires running a full node (or connecting via RPC)</div>
)}
<Title isReadOnly={isReadOnly} />
<Description isReadOnly={isReadOnly} />
{!isInCreateSubplebbitView && <Address isReadOnly={isReadOnly} />}
<Logo isReadOnly={isReadOnly} />
<Rules isReadOnly={isReadOnly} />
<Moderators isReadOnly={isReadOnly} />
<Challenges isReadOnly={isReadOnly} readOnlyChallenges={subplebbit?.challenges} challengeNames={challengeNames} challengesSettings={rpcChallenges} />
<Challenges isReadOnly={isChallengesReadOnly} readOnlyChallenges={subplebbit?.challenges} challengeNames={challengeNames} challengesSettings={rpcChallenges} />
{!isInCreateSubplebbitView && <JSONSettings isReadOnly={isReadOnly} />}
<div className={styles.saveOptions}>
{!isInCreateSubplebbitView && !isReadOnly && (

View File

@@ -7,6 +7,7 @@ import { VitePWA } from 'vite-plugin-pwa';
import reactScan from '@react-scan/vite-plugin-react-scan';
const isProduction = process.env.NODE_ENV === 'production';
const isDevelopment = process.env.NODE_ENV === 'development';
export default defineConfig({
plugins: [
@@ -19,7 +20,8 @@ export default defineConfig({
]
}
}),
!isProduction && reactScan({
// Only include React Scan in development mode - never in production builds
(isDevelopment || (!isProduction && process.env.NODE_ENV !== 'production')) && reactScan({
showToolbar: true,
playSound: true,
}),
@@ -86,12 +88,13 @@ export default defineConfig({
navigateFallbackDenylist: [/^\/api/, /^\/_(.*)/],
runtimeCaching: [
// Fix index.html not refreshing on new versions
// Always get fresh HTML from network first
{
urlPattern: ({ url }) => url.pathname === '/' || url.pathname === '/index.html',
handler: 'StaleWhileRevalidate',
handler: 'NetworkFirst',
options: {
cacheName: 'html-cache'
cacheName: 'html-cache',
networkTimeoutSeconds: 3
}
},
// PNG caching