Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 9 additions & 161 deletions src/components/RemoteDiscussionDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,5 @@
import { type ReactNode, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";
import type { ReactNode } from "react";
import { RemoteInteractionButton } from "~/components/RemoteInteractionButton";

export function RemoteDiscussionDialog({
apUrl,
Expand All @@ -22,155 +12,13 @@ export function RemoteDiscussionDialog({
variant?: "button" | "link" | "ghost";
className?: string;
}) {
const [open, setOpen] = useState(false);
const [handle, setHandle] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [interactionUrl, setInteractionUrl] = useState<string | null>(null);
const [domain, setDomain] = useState<string | null>(null);

const handleLookup = async (e: React.FormEvent) => {
e.preventDefault();
const input = handle.trim();
if (!input) {
setError("Please enter your fediverse handle.");
return;
}

if (!/^@?[^@]+@[^@]+\.[^@]+$/.test(input)) {
setError("Invalid format. Use @username@domain.com");
return;
}

setLoading(true);
setError("");

try {
const response = await fetch("/api/instance-lookup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ handle: input }),
});

const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Lookup failed.");
}

const url = data.interactionTemplate.replace(
"{uri}",
encodeURIComponent(apUrl),
);
setInteractionUrl(url);
setDomain(data.domain);
} catch (err) {
setError(err instanceof Error ? err.message : "Lookup failed.");
} finally {
setLoading(false);
}
};

const handleGo = () => {
if (!interactionUrl) return;
window.open(interactionUrl, "_blank", "noopener,noreferrer");
setOpen(false);
};

const handleOpenChange = (next: boolean) => {
setOpen(next);
if (!next) {
setHandle("");
setError("");
setInteractionUrl(null);
setDomain(null);
setLoading(false);
}
};

return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
{variant === "link" ? (
<button
type="button"
className={`text-xs text-muted-foreground hover:text-foreground hover:underline cursor-pointer ${className ?? ""}`}
>
{triggerLabel}
</button>
) : variant === "ghost" ? (
<Button variant="ghost" size="sm" className={`h-auto px-1.5 py-0.5 text-xs text-muted-foreground gap-1 ${className ?? ""}`}>
{triggerLabel}
</Button>
) : (
<Button variant="outline" size="sm" className={className}>
{triggerLabel}
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Join the Discussion</DialogTitle>
<DialogDescription>
If you want to continue this discussion, enter your fediverse handle
to find this post on your instance.
</DialogDescription>
</DialogHeader>

{!interactionUrl ? (
<form onSubmit={handleLookup} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="remoteDiscussHandle">
Your fediverse handle
</Label>
<Input
id="remoteDiscussHandle"
placeholder="@username@mastodon.social"
value={handle}
onChange={(e) => {
setHandle(e.target.value);
if (error) setError("");
}}
disabled={loading}
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<div className="flex gap-2 justify-end">
<Button
type="button"
variant="ghost"
onClick={() => handleOpenChange(false)}
>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading ? "Looking up..." : "Look up"}
</Button>
</div>
</form>
) : (
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
You will be redirected to <strong>{domain}</strong> to find and
reply to this post.
</p>
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex gap-2 justify-end">
<Button
type="button"
variant="ghost"
onClick={() => {
setInteractionUrl(null);
setDomain(null);
setError("");
}}
>
Back
</Button>
<Button onClick={handleGo}>Go to my instance</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
<RemoteInteractionButton
kind="discussion"
target={apUrl}
className={className}
triggerVariant={variant}
triggerLabel={triggerLabel}
/>
);
}
187 changes: 19 additions & 168 deletions src/components/RemoteFollowDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,171 +1,22 @@
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "~/components/ui/dialog";
import { Button } from "~/components/ui/button";
import { Input } from "~/components/ui/input";
import { Label } from "~/components/ui/label";

interface ActorInfo {
name?: string;
handle: string;
icon?: string;
summary?: string;
remoteFollowUrl?: string;
}

export function RemoteFollowDialog({ actorHandle, className, children }: { actorHandle: string; className?: string; children?: React.ReactNode }) {
const [open, setOpen] = useState(false);
const [fediverseId, setFediverseId] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [actorInfo, setActorInfo] = useState<ActorInfo | null>(null);

const handleLookup = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const input = fediverseId.trim();
if (!input) {
setError("Please enter your fediverse handle.");
return;
}

if (!/^@?[^@]+@[^@]+\.[^@]+$/.test(input)) {
setError("Invalid format. Use @username@domain.com");
return;
}

setLoading(true);
setError("");
setActorInfo(null);

try {
const response = await fetch("/api/webfinger", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fediverseId: input, actorHandle }),
});

const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Lookup failed.");
}
if (!data.actor) {
throw new Error("User not found.");
}
setActorInfo(data.actor);
} catch (err) {
setError(err instanceof Error ? err.message : "Lookup failed.");
} finally {
setLoading(false);
}
};

const handleFollow = () => {
if (!actorInfo?.remoteFollowUrl) {
setError("Subscribe template not found for this instance.");
return;
}
window.open(actorInfo.remoteFollowUrl, "_blank", "noopener,noreferrer");
setOpen(false);
};

const handleOpenChange = (next: boolean) => {
setOpen(next);
if (!next) {
setFediverseId("");
setError("");
setActorInfo(null);
setLoading(false);
}
};

import type { ReactNode } from "react";
import { RemoteInteractionButton } from "~/components/RemoteInteractionButton";

export function RemoteFollowDialog({
actorHandle,
className,
children,
}: {
actorHandle: string;
className?: string;
children?: ReactNode;
}) {
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
{children ?? (
<Button variant="outline" size="sm" className={className}>
Remote Follow
</Button>
)}
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Remote Follow</DialogTitle>
<DialogDescription>
To follow <strong>@{actorHandle}</strong>, enter your fediverse handle.
</DialogDescription>
</DialogHeader>

{!actorInfo ? (
<form onSubmit={handleLookup} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="fediverseId">Your fediverse handle</Label>
<Input
id="fediverseId"
placeholder="@username@mastodon.social"
value={fediverseId}
onChange={(e) => {
setFediverseId(e.target.value);
if (error) setError("");
}}
disabled={loading}
/>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<div className="flex gap-2 justify-end">
<Button
type="button"
variant="ghost"
onClick={() => handleOpenChange(false)}
>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading ? "Looking up..." : "Look up"}
</Button>
</div>
</form>
) : (
<div className="space-y-4">
<div className="flex items-start gap-3 rounded-md border p-3">
{actorInfo.icon && (
<img
src={actorInfo.icon}
alt=""
className="size-10 rounded-full shrink-0"
/>
)}
<div className="min-w-0 flex-1">
{actorInfo.name && (
<p className="font-medium truncate">{actorInfo.name}</p>
)}
<p className="text-sm text-muted-foreground truncate">
@{actorInfo.handle}
</p>
</div>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<div className="flex gap-2 justify-end">
<Button
type="button"
variant="ghost"
onClick={() => {
setActorInfo(null);
setError("");
}}
>
Back
</Button>
<Button onClick={handleFollow}>Follow</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
<RemoteInteractionButton
kind="follow"
target={actorHandle}
className={className}
trigger={children}
triggerVariant="split"
/>
);
}
Loading
Loading