Add auto updates and tighten reply feeds

This commit is contained in:
cyph3rasi
2026-03-08 19:22:18 -07:00
parent 5f21ea8e5d
commit 044196cfa7
7 changed files with 281 additions and 41 deletions
+79
View File
@@ -50,10 +50,16 @@ export default function AdminPage() {
lastFinishedAt?: string | null;
lastExitCode?: number | null;
lastError?: string | null;
trigger?: 'manual' | 'auto' | null;
config?: {
autoUpdateEnabled: boolean;
intervalMinutes: number;
};
};
} | null>(null);
const [loadingUpdateStatus, setLoadingUpdateStatus] = useState(false);
const [triggeringUpdate, setTriggeringUpdate] = useState(false);
const [savingAutoUpdate, setSavingAutoUpdate] = useState(false);
useEffect(() => {
fetch('/api/admin/me')
@@ -291,6 +297,38 @@ export default function AdminPage() {
}
};
const handleToggleAutoUpdate = async () => {
if (!updateStatus?.updater.available || !updateStatus.updater.config) return;
const nextValue = !updateStatus.updater.config.autoUpdateEnabled;
setSavingAutoUpdate(true);
try {
const res = await fetch('/api/admin/update', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ autoUpdateEnabled: nextValue }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
throw new Error(data.error || 'Failed to update auto-update setting');
}
setUpdateStatus((prev) => prev ? {
...prev,
updater: {
...prev.updater,
config: data.config,
},
} : prev);
showToast(nextValue ? 'Automatic updates enabled' : 'Automatic updates disabled', 'success');
} catch (error) {
showToast(error instanceof Error ? error.message : 'Failed to update auto-update setting', 'error');
} finally {
setSavingAutoUpdate(false);
}
};
const handleFaviconUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
event.target.value = '';
@@ -710,6 +748,41 @@ export default function AdminPage() {
</button>
</div>
{updateStatus?.updater.available && updateStatus?.updater.config && (
<div style={{
marginTop: '16px',
padding: '12px',
borderRadius: '8px',
border: '1px solid var(--border)',
background: 'var(--background)',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
gap: '16px',
}}>
<div>
<div style={{ fontSize: '13px', fontWeight: 600, marginBottom: '4px' }}>
Automatic updates
</div>
<div style={{ fontSize: '12px', color: 'var(--foreground-secondary)' }}>
Enabled by default. This node checks for updates every {updateStatus.updater.config.intervalMinutes} minutes and installs them automatically.
</div>
</div>
<button
className={`btn btn-sm ${updateStatus.updater.config.autoUpdateEnabled ? 'btn-primary' : 'btn-ghost'}`}
onClick={handleToggleAutoUpdate}
disabled={savingAutoUpdate}
style={{ flexShrink: 0 }}
>
{savingAutoUpdate
? 'Saving...'
: updateStatus.updater.config.autoUpdateEnabled
? 'Disable'
: 'Enable'}
</button>
</div>
)}
<div style={{ display: 'grid', gap: '8px', marginTop: '16px', fontSize: '13px' }}>
<div>
<strong>Current build:</strong>{' '}
@@ -741,6 +814,12 @@ export default function AdminPage() {
{updateStatus.updater.lastExitCode}
</div>
)}
{updateStatus?.updater.trigger && (
<div>
<strong>Last trigger:</strong>{' '}
{updateStatus.updater.trigger === 'auto' ? 'Automatic update' : 'Manual update'}
</div>
)}
{updateStatus?.updater.lastError && (
<div style={{ color: 'var(--danger)' }}>
<strong>Last error:</strong>{' '}
+25 -1
View File
@@ -1,7 +1,7 @@
import { NextResponse } from 'next/server';
import { requireAdmin } from '@/lib/auth/admin';
import { getCurrentBuildInfo, getLatestPublishedBuild, compareBuildVersions } from '@/lib/version';
import { getHostUpdaterStatus, triggerHostUpdate } from '@/lib/host-updater';
import { getHostUpdaterStatus, triggerHostUpdate, updateHostUpdaterConfig } from '@/lib/host-updater';
function isUpdateAvailable(currentVersion: string, latestVersion: string | null | undefined) {
if (!latestVersion) {
@@ -56,3 +56,27 @@ export async function POST() {
);
}
}
export async function PATCH(request: Request) {
try {
await requireAdmin();
const body = await request.json();
if (typeof body.autoUpdateEnabled !== 'boolean') {
return NextResponse.json({ error: 'autoUpdateEnabled must be a boolean' }, { status: 400 });
}
const result = await updateHostUpdaterConfig(body.autoUpdateEnabled);
return NextResponse.json(result);
} catch (error) {
if (error instanceof Error && error.message === 'Admin required') {
return NextResponse.json({ error: 'Admin required' }, { status: 403 });
}
console.error('[Admin Update] Config error:', error);
return NextResponse.json(
{ error: error instanceof Error ? error.message : 'Failed to update auto-update settings' },
{ status: 500 }
);
}
}
+3 -1
View File
@@ -735,7 +735,9 @@ export async function GET(request: Request) {
);
if (!profileData?.posts) return [];
return profileData.posts.map(post => ({
return profileData.posts
.filter((post: any) => !post.replyToId && !post.swarmReplyToId && !post.isReply)
.map(post => ({
id: `swarm:${domain}:${post.id}`,
content: post.content,
createdAt: new Date(post.createdAt),
+4 -2
View File
@@ -6,7 +6,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { db, posts, users, media, nodes } from '@/db';
import { eq, desc, and } from 'drizzle-orm';
import { eq, desc, and, isNull } from 'drizzle-orm';
export interface SwarmUserProfile {
handle: string;
@@ -116,7 +116,9 @@ export async function GET(request: NextRequest, context: RouteContext) {
.where(
and(
eq(posts.userId, user.id),
eq(posts.isRemoved, false)
eq(posts.isRemoved, false),
isNull(posts.replyToId),
isNull(posts.swarmReplyToId)
)
)
.orderBy(desc(posts.createdAt))
+12 -10
View File
@@ -19,6 +19,7 @@ export default function PostDetailPage() {
const [post, setPost] = useState<Post | null>(null);
const [replies, setReplies] = useState<Post[]>([]);
const [replyingTo, setReplyingTo] = useState<Post | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -31,6 +32,7 @@ export default function PostDetailPage() {
const data = await res.json();
setPost(data.post);
setReplies(data.replies || []);
setReplyingTo(data.post);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load post');
} finally {
@@ -47,13 +49,13 @@ export default function PostDetailPage() {
let swarmReplyTo: { postId: string; nodeDomain: string; content?: string; author?: any } | undefined;
let localReplyToId: string | undefined = replyToId;
if (post?.isSwarm && post.nodeDomain && post.originalPostId) {
if (replyingTo?.isSwarm && replyingTo.nodeDomain && replyingTo.originalPostId) {
// This is a reply to a swarm post - send to the origin node
swarmReplyTo = {
postId: post.originalPostId,
nodeDomain: post.nodeDomain,
content: post.content,
author: post.author,
postId: replyingTo.originalPostId,
nodeDomain: replyingTo.nodeDomain,
content: replyingTo.content,
author: replyingTo.author,
};
localReplyToId = undefined; // Can't use UUID foreign key for swarm posts
}
@@ -78,6 +80,7 @@ export default function PostDetailPage() {
if (post) {
setPost({ ...post, repliesCount: post.repliesCount + 1 });
}
setReplyingTo(post);
}
};
@@ -172,9 +175,10 @@ export default function PostDetailPage() {
<div style={{ borderBottom: '1px solid var(--border)' }}>
<Compose
onPost={handlePost}
replyingTo={post}
replyingTo={replyingTo}
isReply
placeholder="Post your reply"
onCancelReply={() => setReplyingTo(post)}
/>
</div>
)}
@@ -188,10 +192,8 @@ export default function PostDetailPage() {
onRepost={handleRepost}
onDelete={handleDelete}
parentPostAuthorId={post.author.id}
onComment={(p) => {
// In detail view, commenting on a reply should probably just focus the main composer
// but we could also implement nested replies later.
// For now, let's keep it simple.
onComment={(replyTarget) => {
setReplyingTo(replyTarget);
const composer = document.querySelector('.compose-input') as HTMLTextAreaElement;
composer?.focus();
}}
+14 -1
View File
@@ -12,6 +12,11 @@ export interface HostUpdaterStatus {
lastExitCode?: number | null;
lastError?: string | null;
pid?: number | null;
trigger?: 'manual' | 'auto' | null;
config?: {
autoUpdateEnabled: boolean;
intervalMinutes: number;
};
}
function getUpdaterConfig() {
@@ -25,7 +30,7 @@ function getUpdaterConfig() {
};
}
function requestUpdater<T>(method: 'GET' | 'POST', path: string, body?: unknown): Promise<T> {
function requestUpdater<T>(method: 'GET' | 'POST' | 'PATCH', path: string, body?: unknown): Promise<T> {
const { socketPath, token, enabled } = getUpdaterConfig();
if (!enabled) {
@@ -114,3 +119,11 @@ export async function getHostUpdaterStatus(): Promise<HostUpdaterStatus> {
export async function triggerHostUpdate() {
return requestUpdater<{ ok: boolean; status: string; message?: string }>('POST', '/update');
}
export async function updateHostUpdaterConfig(autoUpdateEnabled: boolean) {
return requestUpdater<{ ok: boolean; config: { autoUpdateEnabled: boolean; intervalMinutes: number } }>(
'PATCH',
'/config',
{ autoUpdateEnabled }
);
}