-
+
+
+
{signedAccountId && }
- {signedAccountId && (
-
-
NEAR: {signedAccountId}
- {isConnected && handle && (
-
Twitter: @{handle}
- )}
-
- )}
{/*
-
{children}
+
{children}
);
diff --git a/src/hooks/use-post-management.js b/src/hooks/use-post-management.js
new file mode 100644
index 0000000..8711d1c
--- /dev/null
+++ b/src/hooks/use-post-management.js
@@ -0,0 +1,170 @@
+import { useCallback, useMemo } from "react";
+import { debounce } from "lodash";
+
+const IMAGE_PLACEHOLDER = "[IMAGE]";
+
+export function usePostManagement(posts, setPosts, saveAutoSave) {
+ // Memoized function to get combined text for single post mode
+ const getCombinedText = useMemo(() => {
+ return posts
+ .map((p, i) => {
+ let text = p.text;
+ // Only add [IMAGE] placeholder if post has media and it's not already there
+ if (p.mediaId && i > 0 && !text.includes(IMAGE_PLACEHOLDER)) {
+ text = `${IMAGE_PLACEHOLDER}\n${text}`;
+ }
+ return text;
+ })
+ .filter((t) => t !== null && t !== undefined && t !== "")
+ .join("\n---\n")
+ .trim(); // Used when converting from thread mode to single mode
+ }, [posts]);
+
+ // Split text into posts while properly handling [IMAGE] placeholders
+ const splitTextIntoPosts = useCallback(
+ (combinedText) => {
+ // Split on "---" when it's on its own line
+ const parts = combinedText
+ .split(/\n---\n/)
+ .map((t) => t.trim()) // Trim each part to remove extra newlines
+ .filter((t) => t);
+
+ // Create new posts array
+ const newPosts = parts.map((text, index) => {
+ // Check if text contains [IMAGE] placeholder exactly
+ const hasImagePlaceholder = text.includes(IMAGE_PLACEHOLDER);
+
+ // Clean the text by removing the exact [IMAGE] placeholder and any following newline
+ const cleanText = hasImagePlaceholder
+ ? text
+ .replace(`${IMAGE_PLACEHOLDER}\n`, "")
+ .replace(IMAGE_PLACEHOLDER, "")
+ .trim()
+ : text;
+
+ // Always preserve media from original posts if available
+ if (index < posts.length) {
+ return {
+ text: cleanText,
+ mediaId: posts[index].mediaId,
+ mediaPreview: posts[index].mediaPreview,
+ };
+ }
+
+ return { text: cleanText, mediaId: null, mediaPreview: null };
+ });
+
+ // If we have fewer parts than original posts, preserve remaining posts' media
+ if (parts.length < posts.length) {
+ for (let i = parts.length; i < posts.length; i++) {
+ if (posts[i].mediaId) {
+ newPosts.push({
+ text: "",
+ mediaId: posts[i].mediaId,
+ mediaPreview: posts[i].mediaPreview,
+ });
+ }
+ }
+ }
+
+ return newPosts.length > 0
+ ? newPosts
+ : [{ text: "", mediaId: null, mediaPreview: null }];
+ },
+ [posts],
+ );
+
+ // Debounced autosave function
+ const debouncedAutoSave = useMemo(
+ () =>
+ debounce((posts) => {
+ saveAutoSave(posts);
+ }, 1000),
+ [saveAutoSave],
+ );
+
+ // Handle text changes
+ const handleTextChange = useCallback(
+ (index, value) => {
+ setPosts((currentPosts) => {
+ const newPosts = [...currentPosts];
+ newPosts[index] = { ...newPosts[index], text: value };
+ debouncedAutoSave(newPosts);
+ return newPosts;
+ });
+ },
+ [setPosts, debouncedAutoSave],
+ );
+
+ // Convert between modes
+ const convertToThread = useCallback(
+ (singleText) => {
+ // Only split if there's a "---" surrounded by newlines
+ if (singleText.includes("\n---\n")) {
+ const newPosts = splitTextIntoPosts(singleText);
+ setPosts(newPosts);
+ } else {
+ // If no splits, preserve the current post with its media preview and ID
+ setPosts([
+ {
+ ...posts[0],
+ mediaPreview: posts[0].mediaPreview,
+ mediaId: posts[0].mediaId,
+ },
+ ]);
+ }
+ },
+ [splitTextIntoPosts, setPosts, posts],
+ );
+
+ const convertToSingle = useCallback(() => {
+ // Preserve media preview and ID from the first post
+ const firstPost = posts[0];
+ const newPost = {
+ text: getCombinedText,
+ mediaId: firstPost.mediaId,
+ mediaPreview: firstPost.mediaPreview,
+ };
+ setPosts([newPost]);
+ debouncedAutoSave([newPost]);
+ }, [getCombinedText, posts, setPosts, debouncedAutoSave]);
+
+ // Thread management functions
+ const addThread = useCallback(() => {
+ setPosts((currentPosts) => {
+ const newPosts = [
+ ...currentPosts,
+ { text: "", mediaId: null, mediaPreview: null },
+ ];
+ debouncedAutoSave(newPosts);
+ return newPosts;
+ });
+ }, [setPosts, debouncedAutoSave]);
+
+ const removeThread = useCallback(
+ (index) => {
+ setPosts((currentPosts) => {
+ if (currentPosts.length <= 1) return currentPosts;
+ const newPosts = currentPosts.filter((_, i) => i !== index);
+ debouncedAutoSave(newPosts);
+ return newPosts;
+ });
+ },
+ [setPosts, debouncedAutoSave],
+ );
+
+ // Cleanup function
+ const cleanup = useCallback(() => {
+ debouncedAutoSave.cancel();
+ }, [debouncedAutoSave]);
+
+ return {
+ handleTextChange,
+ addThread,
+ removeThread,
+ cleanup,
+ convertToThread,
+ convertToSingle,
+ getCombinedText,
+ };
+}
diff --git a/src/hooks/use-post-media.js b/src/hooks/use-post-media.js
new file mode 100644
index 0000000..8526c49
--- /dev/null
+++ b/src/hooks/use-post-media.js
@@ -0,0 +1,86 @@
+import { useCallback } from "react";
+
+export function usePostMedia(setPosts, setError, saveAutoSave) {
+ const handleMediaUpload = useCallback(
+ async (index, file) => {
+ if (!file) return;
+
+ // Create preview URL
+ const previewUrl = URL.createObjectURL(file);
+
+ setPosts((posts) => {
+ const newPosts = [...posts];
+ newPosts[index] = {
+ ...newPosts[index],
+ mediaPreview: previewUrl,
+ };
+ return newPosts;
+ });
+
+ // Upload to Twitter
+ try {
+ const formData = new FormData();
+ formData.append("media", file);
+
+ const response = await fetch("/api/twitter/upload", {
+ method: "POST",
+ body: formData,
+ });
+
+ const data = await response.json();
+ if (!response.ok) {
+ throw new Error(data.error || "Upload failed");
+ }
+
+ const { mediaId } = data;
+
+ setPosts((posts) => {
+ const newPosts = [...posts];
+ newPosts[index] = {
+ ...newPosts[index],
+ mediaId,
+ };
+ saveAutoSave(newPosts);
+ return newPosts;
+ });
+ } catch (error) {
+ console.error("Media upload error:", error);
+ setError({
+ title: "Media Upload Failed",
+ description: error.message || "Failed to upload media",
+ variant: "destructive",
+ });
+
+ // Remove preview on error
+ setPosts((posts) => {
+ const newPosts = [...posts];
+ newPosts[index] = {
+ ...newPosts[index],
+ mediaPreview: null,
+ mediaId: null,
+ };
+ return newPosts;
+ });
+ }
+ },
+ [setPosts, setError, saveAutoSave],
+ );
+
+ const removeMedia = useCallback(
+ (index) => {
+ setPosts((posts) => {
+ const newPosts = [...posts];
+ newPosts[index] = {
+ ...newPosts[index],
+ mediaId: null,
+ mediaPreview: null,
+ };
+ saveAutoSave(newPosts);
+ return newPosts;
+ });
+ },
+ [setPosts, saveAutoSave],
+ );
+
+ return { handleMediaUpload, removeMedia };
+}
diff --git a/src/hooks/use-toast.js b/src/hooks/use-toast.js
new file mode 100644
index 0000000..e5c8f5f
--- /dev/null
+++ b/src/hooks/use-toast.js
@@ -0,0 +1,155 @@
+"use client";
+// Inspired by react-hot-toast library
+import * as React from "react";
+
+const TOAST_LIMIT = 3;
+const TOAST_REMOVE_DELAY = 15000;
+
+const actionTypes = {
+ ADD_TOAST: "ADD_TOAST",
+ UPDATE_TOAST: "UPDATE_TOAST",
+ DISMISS_TOAST: "DISMISS_TOAST",
+ REMOVE_TOAST: "REMOVE_TOAST",
+};
+
+let count = 0;
+
+function genId() {
+ count = (count + 1) % Number.MAX_SAFE_INTEGER;
+ return count.toString();
+}
+
+const toastTimeouts = new Map();
+
+const addToRemoveQueue = (toastId) => {
+ if (toastTimeouts.has(toastId)) {
+ return;
+ }
+
+ const timeout = setTimeout(() => {
+ toastTimeouts.delete(toastId);
+ dispatch({
+ type: "REMOVE_TOAST",
+ toastId: toastId,
+ });
+ }, TOAST_REMOVE_DELAY);
+
+ toastTimeouts.set(toastId, timeout);
+};
+
+export const reducer = (state, action) => {
+ switch (action.type) {
+ case "ADD_TOAST":
+ return {
+ ...state,
+ toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
+ };
+
+ case "UPDATE_TOAST":
+ return {
+ ...state,
+ toasts: state.toasts.map((t) =>
+ t.id === action.toast.id ? { ...t, ...action.toast } : t,
+ ),
+ };
+
+ case "DISMISS_TOAST": {
+ const { toastId } = action;
+
+ // ! Side effects ! - This could be extracted into a dismissToast() action,
+ // but I'll keep it here for simplicity
+ if (toastId) {
+ addToRemoveQueue(toastId);
+ } else {
+ state.toasts.forEach((toast) => {
+ addToRemoveQueue(toast.id);
+ });
+ }
+
+ return {
+ ...state,
+ toasts: state.toasts.map((t) =>
+ t.id === toastId || toastId === undefined
+ ? {
+ ...t,
+ open: false,
+ }
+ : t,
+ ),
+ };
+ }
+ case "REMOVE_TOAST":
+ if (action.toastId === undefined) {
+ return {
+ ...state,
+ toasts: [],
+ };
+ }
+ return {
+ ...state,
+ toasts: state.toasts.filter((t) => t.id !== action.toastId),
+ };
+ }
+};
+
+const listeners = [];
+
+let memoryState = { toasts: [] };
+
+function dispatch(action) {
+ memoryState = reducer(memoryState, action);
+ listeners.forEach((listener) => {
+ listener(memoryState);
+ });
+}
+
+function toast({ ...props }) {
+ const id = genId();
+
+ const update = (props) =>
+ dispatch({
+ type: "UPDATE_TOAST",
+ toast: { ...props, id },
+ });
+ const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
+
+ dispatch({
+ type: "ADD_TOAST",
+ toast: {
+ ...props,
+ id,
+ open: true,
+ onOpenChange: (open) => {
+ if (!open) dismiss();
+ },
+ },
+ });
+
+ return {
+ id: id,
+ dismiss,
+ update,
+ };
+}
+
+function useToast() {
+ const [state, setState] = React.useState(memoryState);
+
+ React.useEffect(() => {
+ listeners.push(setState);
+ return () => {
+ const index = listeners.indexOf(setState);
+ if (index > -1) {
+ listeners.splice(index, 1);
+ }
+ };
+ }, [state]);
+
+ return {
+ ...state,
+ toast,
+ dismiss: (toastId) => dispatch({ type: "DISMISS_TOAST", toastId }),
+ };
+}
+
+export { useToast, toast };
diff --git a/src/lib/near-social.js b/src/lib/near-social.js
index 6d1d0df..6a514f2 100644
--- a/src/lib/near-social.js
+++ b/src/lib/near-social.js
@@ -1,4 +1,31 @@
+import { NETWORK_ID } from "@/config";
+import { Social } from "@builddao/near-social-js";
+
export const SOCIAL_CONTRACT = {
mainnet: "social.near",
testnet: "v1.social08.testnet",
};
+
+export const NearSocialClient = new Social({
+ contractId: SOCIAL_CONTRACT[NETWORK_ID],
+ network: NETWORK_ID,
+});
+
+export const getImageUrl = (image) => {
+ if (!image) return "";
+ if (image.url) return image.url;
+ if (image.ipfs_cid) return `https://ipfs.near.social/ipfs/${image.ipfs_cid}`;
+ return "";
+};
+
+export async function getProfile(accountId) {
+ const response = await NearSocialClient.get({
+ keys: [`${accountId}/profile/**`],
+ });
+ if (!response) {
+ throw new Error("Failed to fetch profile");
+ }
+ const { profile } = response[accountId];
+
+ return profile;
+}
diff --git a/src/pages/_app.js b/src/pages/_app.js
index bc61848..7a2f4e6 100644
--- a/src/pages/_app.js
+++ b/src/pages/_app.js
@@ -5,10 +5,12 @@ import "../styles/globals.css";
import { Footer } from "@/components/footer";
import { GithubForkRibbon } from "@/components/github-fork-ribbon";
+import { Toaster } from "@/components/ui/toaster";
import { SOCIAL_CONTRACT } from "@/lib/near-social";
import { NETWORK_ID } from "../config";
import { NearContext, Wallet } from "../wallets/near";
import { WindowContainer } from "@/components/window-container";
+import { HelperBuddy } from "@/components/helper-buddy";
const wallet = new Wallet({
networkId: NETWORK_ID,
@@ -17,7 +19,6 @@ const wallet = new Wallet({
export default function App({ Component, pageProps }) {
const [signedAccountId, setSignedAccountId] = useState("");
-
useEffect(() => {
// Start up NEAR wallet
wallet.startUp(setSignedAccountId);
@@ -47,7 +48,7 @@ export default function App({ Component, pageProps }) {
property="og:description"
content="Open source user interface to crosspost across Twitter (X) and Near Social platforms."
/>
-
+
@@ -65,7 +66,7 @@ export default function App({ Component, pageProps }) {
property="twitter:description"
content="Open source user interface to crosspost across Twitter (X) and Near Social platforms."
/>
-
+
@@ -74,6 +75,8 @@ export default function App({ Component, pageProps }) {
+
+
>
);
}
diff --git a/src/pages/api/twitter/callback.js b/src/pages/api/twitter/callback.js
index f4467ec..4319a64 100644
--- a/src/pages/api/twitter/callback.js
+++ b/src/pages/api/twitter/callback.js
@@ -6,17 +6,36 @@ export default async function handler(req, res) {
return res.status(405).json({ error: "Method not allowed" });
}
- const { code, state } = req.query;
+ const { code, state, error, error_description } = req.query;
const cookies = parse(req.headers.cookie || "");
const { code_verifier, oauth_state } = cookies;
- if (!code || !state || !code_verifier || !oauth_state) {
- return res.status(400).json({ error: "Missing OAuth parameters" });
+ // Handle OAuth errors (e.g., user denied access)
+ if (error) {
+ console.log("OAuth error:", error, error_description);
+ return res.redirect(
+ `/?twitter_error=${"Twitter access was denied: " + encodeURIComponent(error)}`,
+ );
+ }
+
+ // Validate OAuth parameters
+ if (!code || !state) {
+ return res.redirect(
+ `/?twitter_error=${encodeURIComponent("Missing authorization code")}`,
+ );
+ }
+
+ if (!code_verifier || !oauth_state) {
+ return res.redirect(
+ `/?twitter_error=${encodeURIComponent("Invalid session state")}`,
+ );
}
// Verify the state parameter to prevent CSRF attacks
if (state !== oauth_state) {
- return res.status(400).json({ error: "Invalid OAuth state" });
+ return res.redirect(
+ `/?twitter_error=${encodeURIComponent("Invalid OAuth state")}`,
+ );
}
try {
@@ -27,6 +46,9 @@ export default async function handler(req, res) {
state,
);
+ // Get user info using the new access token
+ const userInfo = await twitterService.getUserInfo(accessToken);
+
// Store tokens in HttpOnly cookies
res.setHeader("Set-Cookie", [
`twitter_access_token=${accessToken}; Path=/; HttpOnly; SameSite=Lax`,
@@ -35,12 +57,12 @@ export default async function handler(req, res) {
"oauth_state=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0",
]);
- // Clean redirect since we're using Zustand store
- res.redirect("/");
+ // Redirect with user info
+ res.redirect(`/?twitter_connected=true&handle=${userInfo.username}`);
} catch (error) {
console.error("Twitter callback error:", error);
- res
- .status(500)
- .json({ error: "Failed to complete Twitter authentication" });
+ return res.redirect(
+ `/?twitter_error=${encodeURIComponent("Failed to complete Twitter authentication")}`,
+ );
}
}
diff --git a/src/pages/api/twitter/upload.js b/src/pages/api/twitter/upload.js
new file mode 100644
index 0000000..360d9f1
--- /dev/null
+++ b/src/pages/api/twitter/upload.js
@@ -0,0 +1,79 @@
+import { TwitterService } from "../../../services/twitter";
+import { parse } from "cookie";
+import formidable from "formidable";
+import fs from "fs";
+
+export const config = {
+ api: {
+ bodyParser: false,
+ },
+};
+
+export default async function handler(req, res) {
+ if (req.method !== "POST") {
+ return res.status(405).json({ error: "Method not allowed" });
+ }
+
+ const cookies = parse(req.headers.cookie || "");
+ const accessToken = cookies.twitter_access_token;
+ if (!accessToken) {
+ return res.status(401).json({ error: "Not authenticated with Twitter" });
+ }
+
+ try {
+ const form = formidable({});
+ const [_, files] = await form.parse(req);
+
+ if (!files.media?.[0]) {
+ return res.status(400).json({ error: "No media file provided" });
+ }
+
+ const file = files.media[0];
+ const twitterService = await TwitterService.initialize();
+
+ if (!twitterService.oauth1Client) {
+ console.error("OAuth 1.0a client not initialized - check credentials");
+ return res.status(500).json({
+ error:
+ "Server is not configured for media uploads. OAuth 1.0a credentials are missing.",
+ });
+ }
+
+ try {
+ const mediaId = await twitterService.uploadMedia(
+ file.filepath,
+ file.mimetype,
+ );
+ return res.status(200).json({
+ success: true,
+ mediaId,
+ });
+ } catch (error) {
+ console.error("Upload failed:", error);
+ return res.status(500).json({
+ error: "Failed to upload media: " + (error.message || "Unknown error"),
+ });
+ } finally {
+ // Clean up the temporary file
+ try {
+ fs.unlinkSync(file.filepath);
+ } catch (error) {
+ console.error("Failed to clean up temp file:", error);
+ }
+ }
+ } catch (error) {
+ console.error("Media upload error:", error);
+ if (
+ error.message === "OAuth 1.0a credentials are required for media uploads"
+ ) {
+ res.status(500).json({
+ error:
+ "Server is not configured for media uploads. Please ensure OAuth 1.0a credentials are set.",
+ });
+ } else {
+ res
+ .status(500)
+ .json({ error: "Failed to upload media: " + error.message });
+ }
+ }
+}
diff --git a/src/pages/index.js b/src/pages/index.js
index 8918333..7adb48c 100644
--- a/src/pages/index.js
+++ b/src/pages/index.js
@@ -1,37 +1,48 @@
-import { useTwitterConnection } from "@/store/twitter-store";
+import { TwitterApiNotice } from "@/components/twitter-api-notice";
+import { NEAR_SOCIAL_ENABLED, TWITTER_ENABLED } from "@/config";
+import { tweet } from "@/lib/twitter";
+import { useNearSocialPost } from "@/store/near-social-store";
import { useContext } from "react";
import { ComposePost } from "../components/compose-post";
import { NearContext } from "../wallets/near";
-import { NEAR_SOCIAL_ENABLED, TWITTER_ENABLED } from "@/config";
-import { useNearSocialPost } from "@/store/near-social-store";
-import { tweet } from "@/lib/twitter";
-import { TwitterApiNotice } from "@/components/twitter-api-notice";
+import { toast } from "@/hooks/use-toast";
export default function Home() {
const { signedAccountId } = useContext(NearContext);
- const { isConnected } = useTwitterConnection();
+
const { post: postToNearSocial } = useNearSocialPost(); // currently needed, so we can "hydrate" client with wallet
// posts to all the enabled target platforms
- // errors are handled in ComposePost
const post = async (posts) => {
- // TODO: generic interface for external plugins
- const promises = [];
+ try {
+ // TODO: generic interface for external plugins
+ const promises = [];
- if (NEAR_SOCIAL_ENABLED) {
- promises.push(postToNearSocial(posts));
- }
+ if (NEAR_SOCIAL_ENABLED) {
+ promises.push(postToNearSocial(posts));
+ }
- if (TWITTER_ENABLED) {
- promises.push(tweet(posts));
- }
+ if (TWITTER_ENABLED) {
+ promises.push(tweet(posts));
+ }
- await Promise.all(promises); // execute all postings
+ await Promise.all(promises); // execute all postings
+ toast({
+ title: "Post Successful",
+ description: "Your post has been published",
+ });
+ } catch (e) {
+ toast({
+ title: "Post Failed",
+ description: e.message || "An unexpected error occurred",
+ variant: "destructive",
+ });
+ }
};
return (
- {/* */}
+
{/* MAIN CONTENT */}
{!signedAccountId ? (
@@ -48,8 +59,9 @@ export default function Home() {
//
//
// )
-
-
+ <>
+
+ >
)}
);
diff --git a/src/pages/profile/[accountId].js b/src/pages/profile/[accountId].js
new file mode 100644
index 0000000..91647c5
--- /dev/null
+++ b/src/pages/profile/[accountId].js
@@ -0,0 +1,50 @@
+import { useRouter } from "next/router";
+import { useEffect, useState } from "react";
+import { getProfile } from "../../lib/near-social";
+import { ProfileView } from "../../components/social/profile";
+
+export default function ProfilePage() {
+ const router = useRouter();
+ const { accountId } = router.query;
+ const [profile, setProfile] = useState(null);
+ const [error, setError] = useState(null);
+ const [loading, setLoading] = useState(true);
+
+ useEffect(() => {
+ if (!accountId) return;
+
+ const fetchProfile = async () => {
+ try {
+ const data = await getProfile(accountId);
+ setProfile(data);
+ } catch (err) {
+ setError(err.message);
+ // Don't redirect on error, just show the error message
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchProfile();
+ }, [accountId]);
+
+ if (loading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return
{error}
;
+ }
+
+ if (!profile) {
+ return (
+
No profile found
+ );
+ }
+
+ return
;
+}
diff --git a/src/services/near-social.js b/src/services/near-social.js
index b0f62ba..7da89af 100644
--- a/src/services/near-social.js
+++ b/src/services/near-social.js
@@ -1,15 +1,6 @@
import { Social, transformActions } from "@builddao/near-social-js";
import { NETWORK_ID } from "../config";
-
-const SOCIAL_CONTRACT = {
- mainnet: "social.near",
- testnet: "v1.social08.testnet",
-};
-
-const NearSocialClient = new Social({
- contractId: SOCIAL_CONTRACT[NETWORK_ID],
- network: NETWORK_ID,
-});
+import { NearSocialClient, SOCIAL_CONTRACT } from "@/lib/near-social";
// This service is used in the client context,
// Uses wallet connection to sign public transactions
diff --git a/src/services/twitter.js b/src/services/twitter.js
index 0c4f904..3f4759a 100644
--- a/src/services/twitter.js
+++ b/src/services/twitter.js
@@ -1,4 +1,5 @@
import { TwitterApi } from "twitter-api-v2";
+import fs from "fs";
// This service is used in the Next.js server context,
// which manages API credentials and handles OAuth communciation with Twitter
@@ -15,19 +16,29 @@ export class TwitterService {
clientSecret: credentials.clientSecret,
});
- // OAuth 1.0a client for user operations if credentials are provided
+ // OAuth 1.0a client for user operations and media uploads if credentials are provided
if (
credentials.apiKey &&
credentials.apiSecret &&
credentials.accessToken &&
credentials.accessSecret
) {
- this.oauth1Client = new TwitterApi({
- appKey: credentials.apiKey,
- appSecret: credentials.apiSecret,
- accessToken: credentials.accessToken,
- accessSecret: credentials.accessSecret,
- });
+ try {
+ this.oauth1Client = new TwitterApi({
+ appKey: credentials.apiKey,
+ appSecret: credentials.apiSecret,
+ accessToken: credentials.accessToken,
+ accessSecret: credentials.accessSecret,
+ });
+ console.log("OAuth 1.0a client initialized successfully");
+ } catch (error) {
+ console.error("Failed to initialize OAuth 1.0a client:", error);
+ this.oauth1Client = null;
+ }
+ } else {
+ console.warn(
+ "Missing OAuth 1.0a credentials - media uploads will not work",
+ );
}
}
@@ -61,34 +72,81 @@ export class TwitterService {
});
}
+ async uploadMedia(mediaPath, mimeType) {
+ if (!this.oauth1Client) {
+ throw new Error("OAuth 1.0a credentials are required for media uploads");
+ }
+
+ try {
+ // Read the file into a buffer
+ const buffer = await fs.promises.readFile(mediaPath);
+
+ // For media uploads, we use the app-only OAuth 1.0a client
+ // The user's OAuth 2.0 access token is not used for media uploads
+ const mediaId = await this.oauth1Client.v1.uploadMedia(buffer, {
+ mimeType,
+ });
+ console.log("Media upload successful, mediaId:", mediaId);
+ return mediaId;
+ } catch (error) {
+ console.error("Media upload error details:", error);
+ throw error;
+ }
+ }
+
async tweet(accessToken, posts) {
- // Create OAuth 2.0 client with user access token for tweet operations
- const userClient = new TwitterApi(accessToken);
+ // If no access token is provided, user is not connected
+ if (!accessToken) {
+ throw new Error(
+ "Authentication required: Please connect your Twitter account",
+ );
+ }
// Handle array of post objects
if (!Array.isArray(posts)) {
throw new Error("Posts must be an array");
}
- if (posts.length === 1) {
- // Single tweet
- return userClient.v2.tweet(posts[0].text);
- } else {
- // Thread implementation
- let lastTweetId = null;
- const responses = [];
-
- for (const post of posts) {
- const tweetData = lastTweetId
- ? { text: post.text, reply: { in_reply_to_tweet_id: lastTweetId } }
- : { text: post.text };
-
- const response = await userClient.v2.tweet(tweetData);
- responses.push(response);
- lastTweetId = response.data.id;
+ try {
+ // Create OAuth 2.0 client with user access token for tweet operations
+ const userClient = new TwitterApi(accessToken);
+
+ if (posts.length === 1) {
+ const post = posts[0];
+ const tweetData = { text: post.text };
+
+ // Add media if present
+ if (post.mediaId) {
+ tweetData.media = { media_ids: [post.mediaId] };
+ }
+
+ return userClient.v2.tweet(tweetData);
+ } else {
+ // Thread implementation
+ let lastTweetId = null;
+ const responses = [];
+
+ for (const post of posts) {
+ const tweetData = {
+ text: post.text,
+ ...(lastTweetId && {
+ reply: { in_reply_to_tweet_id: lastTweetId },
+ }),
+ ...(post.mediaId && { media: { media_ids: [post.mediaId] } }),
+ };
+
+ const response = await userClient.v2.tweet(tweetData);
+ responses.push(response);
+ lastTweetId = response.data.id;
+ }
+
+ return responses;
}
-
- return responses;
+ } catch (error) {
+ console.error("Failed to post tweet:", error);
+ throw new Error(
+ "Failed to post tweet: " + (error.message || "Please try again"),
+ );
}
}
@@ -100,11 +158,15 @@ export class TwitterService {
}
try {
- const me = await this.oauth1Client.v2.me();
+ // Create OAuth 2.0 client with user access token for user operations
+ const userClient = new TwitterApi(accessToken);
+ const me = await userClient.v2.me();
return me.data;
} catch (error) {
console.error("Failed to fetch user info:", error);
- throw error;
+ // Return null instead of throwing error when token is invalid
+ // This indicates user is not connected
+ return null;
}
}
}
diff --git a/src/store/drafts-store.js b/src/store/drafts-store.js
index 3796be1..ab96de8 100644
--- a/src/store/drafts-store.js
+++ b/src/store/drafts-store.js
@@ -2,73 +2,152 @@ import { create } from "zustand";
const STORAGE_KEY = "crosspost_drafts";
const AUTOSAVE_KEY = "crosspost_autosave";
+const MODE_KEY = "crosspost_mode";
-const loadDrafts = (key = STORAGE_KEY) => {
- if (typeof window === "undefined") return [];
+const loadFromStorage = (key) => {
+ if (typeof window === "undefined") return null;
try {
const saved = localStorage.getItem(key);
- return saved ? JSON.parse(saved) : [];
+ return saved ? JSON.parse(saved) : null;
} catch (err) {
- console.error("Failed to load drafts:", err);
- return [];
+ console.error(`Failed to load from ${key}:`, err);
+ return null;
}
};
-const saveDrafts = (drafts, key = STORAGE_KEY) => {
+const saveToStorage = (key, data) => {
if (typeof window === "undefined") return;
try {
- localStorage.setItem(key, JSON.stringify(drafts));
+ localStorage.setItem(key, JSON.stringify(data));
} catch (err) {
- console.error("Failed to save drafts:", err);
+ console.error(`Failed to save to ${key}:`, err);
}
};
+const getInitialState = () => {
+ if (typeof window === "undefined") {
+ return {
+ drafts: [],
+ autosave: null,
+ isThreadMode: false,
+ isModalOpen: false,
+ };
+ }
+
+ return {
+ drafts: loadFromStorage(STORAGE_KEY) || [],
+ autosave: loadFromStorage(AUTOSAVE_KEY),
+ isThreadMode: loadFromStorage(MODE_KEY) || false,
+ isModalOpen: false,
+ };
+};
+
export const useDraftsStore = create((set, get) => ({
- drafts: loadDrafts(),
- autosave: loadDrafts(AUTOSAVE_KEY),
- isModalOpen: false,
+ ...getInitialState(),
setModalOpen: (isOpen) => set({ isModalOpen: isOpen }),
+ setThreadMode: (isThreadMode) => {
+ try {
+ saveToStorage(MODE_KEY, isThreadMode);
+ set({ isThreadMode });
+ } catch (err) {
+ console.error("Failed to save thread mode:", err);
+ // Still update state even if storage fails
+ set({ isThreadMode });
+ }
+ },
+
saveDraft: (posts) => {
- const draft = {
- id: Date.now(),
- posts,
- createdAt: new Date().toISOString(),
- };
- set((state) => {
- const newDrafts = [draft, ...state.drafts];
- saveDrafts(newDrafts);
- return { drafts: newDrafts };
- });
+ try {
+ // Validate posts
+ if (
+ !Array.isArray(posts) ||
+ posts.length === 0 ||
+ posts.every((p) => !p?.text?.trim())
+ ) {
+ return;
+ }
+
+ // Clean posts data before saving
+ const cleanPosts = posts.map((post) => ({
+ text: post.text || "",
+ mediaId: post.mediaId || null,
+ mediaPreview: post.mediaPreview || null,
+ }));
+
+ const draft = {
+ id: Date.now(),
+ posts: cleanPosts,
+ createdAt: new Date().toISOString(),
+ };
+
+ set((state) => {
+ const newDrafts = [draft, ...state.drafts];
+ saveToStorage(STORAGE_KEY, newDrafts);
+ return { drafts: newDrafts };
+ });
+ } catch (err) {
+ console.error("Failed to save draft:", err);
+ }
},
deleteDraft: (id) => {
- set((state) => {
- const newDrafts = state.drafts.filter((d) => d.id !== id);
- saveDrafts(newDrafts);
- return { drafts: newDrafts };
- });
+ try {
+ set((state) => {
+ const newDrafts = state.drafts.filter((d) => d.id !== id);
+ saveToStorage(STORAGE_KEY, newDrafts);
+ return { drafts: newDrafts };
+ });
+ } catch (err) {
+ console.error("Failed to delete draft:", err);
+ }
},
saveAutoSave: (posts) => {
- if (posts.every((p) => !p.text.trim())) {
- // If all posts are empty, clear autosave
- localStorage.removeItem(AUTOSAVE_KEY);
- set({ autosave: null });
- return;
- }
+ try {
+ // Validate posts
+ if (
+ !Array.isArray(posts) ||
+ posts.length === 0 ||
+ posts.every((p) => !p?.text?.trim())
+ ) {
+ localStorage.removeItem(AUTOSAVE_KEY);
+ set({ autosave: null });
+ return;
+ }
- const autosave = {
- posts,
- updatedAt: new Date().toISOString(),
- };
- saveDrafts(autosave, AUTOSAVE_KEY);
- set({ autosave });
+ // Clean posts data before saving
+ const cleanPosts = posts.map((post) => ({
+ text: post.text || "",
+ mediaId: post.mediaId || null,
+ mediaPreview: post.mediaPreview || null,
+ }));
+
+ const autosave = {
+ posts: cleanPosts,
+ updatedAt: new Date().toISOString(),
+ };
+ saveToStorage(AUTOSAVE_KEY, autosave);
+ set({ autosave });
+ } catch (err) {
+ console.error("Failed to save autosave:", err);
+ // Attempt to clear autosave on error
+ try {
+ localStorage.removeItem(AUTOSAVE_KEY);
+ set({ autosave: null });
+ } catch {} // Ignore any errors during cleanup
+ }
},
clearAutoSave: () => {
- localStorage.removeItem(AUTOSAVE_KEY);
- set({ autosave: null });
+ try {
+ localStorage.removeItem(AUTOSAVE_KEY);
+ set({ autosave: null });
+ } catch (err) {
+ console.error("Failed to clear autosave:", err);
+ // Still clear state even if storage fails
+ set({ autosave: null });
+ }
},
}));
diff --git a/src/store/near-social-store.js b/src/store/near-social-store.js
index f34c911..eed90c1 100644
--- a/src/store/near-social-store.js
+++ b/src/store/near-social-store.js
@@ -1,5 +1,6 @@
import { create } from "zustand";
import { NearSocialService } from "../services/near-social";
+import { toast } from "@/hooks/use-toast";
const store = (set, get) => ({
wallet: null,
@@ -10,31 +11,36 @@ const store = (set, get) => ({
set({ wallet, service });
},
// TODO: posting plugin's standard interface
- post: async (content) => {
+ post: async (posts) => {
const { service } = get();
if (!service) {
throw new Error("Near Social service not initialized");
}
try {
- const transaction = await service.createPost(content);
+ const transaction = await service.createPost(posts);
if (!transaction) {
throw new Error("Failed to create post transaction");
}
- await get().wallet.signAndSendTransactions({
- // we're in application state
- // plugin is using it as middleware for signing transactions
- transactions: [
- {
- receiverId: transaction.contractId,
- actions: transaction.actions,
- },
- ],
- });
+ try {
+ await get().wallet.signAndSendTransactions({
+ // we're in application state
+ // plugin is using it as middleware for signing transactions
+ transactions: [
+ {
+ receiverId: transaction.contractId,
+ actions: transaction.actions,
+ },
+ ],
+ });
- return true;
+ return true;
+ } catch (error) {
+ console.error("Near Social post error:", error);
+ throw error;
+ }
} catch (error) {
console.error("Near Social post error:", error);
throw error;
diff --git a/src/store/twitter-store.js b/src/store/twitter-store.js
index a1c20e9..ac35b5a 100644
--- a/src/store/twitter-store.js
+++ b/src/store/twitter-store.js
@@ -6,15 +6,71 @@ import {
tweet,
} from "../lib/twitter";
+const STORAGE_KEY = "twitter_connection";
+
const store = (set, get) => ({
isConnected: false,
isConnecting: false,
handle: null,
error: null,
post: tweet,
+ init: () => {
+ if (typeof window === "undefined") return;
+
+ // First try to restore from URL params (OAuth callback)
+ const params = new URLSearchParams(window.location.search);
+ const isConnected = params.get("twitter_connected") === "true";
+ const handle = params.get("handle");
+ const error = params.get("twitter_error");
+
+ if (isConnected && handle) {
+ // Save to localStorage and state
+ const connectionState = { isConnected: true, handle };
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(connectionState));
+ set({ isConnected: true, handle, isConnecting: false, error: null });
+ } else if (error) {
+ // Handle OAuth errors (e.g., user denied access)
+ const decodedError = decodeURIComponent(error);
+ localStorage.removeItem(STORAGE_KEY);
+ set({
+ isConnected: false,
+ isConnecting: false,
+ handle: null,
+ error: decodedError,
+ });
+ } else {
+ // Try to restore from localStorage
+ try {
+ const saved = localStorage.getItem(STORAGE_KEY);
+ if (saved) {
+ const { isConnected, handle } = JSON.parse(saved);
+ set({ isConnected, handle, isConnecting: false, error: null });
+ }
+ } catch (e) {
+ console.error("Failed to restore Twitter connection state:", e);
+ }
+ }
+ // clean url
+ window.history.replaceState({}, "", "/");
+ },
checkConnection: async () => {
- const { isConnected, handle } = await status();
- set({ isConnected, handle });
+ try {
+ const { isConnected, handle } = await status();
+ // Update localStorage with verified state
+ if (isConnected && handle) {
+ localStorage.setItem(
+ STORAGE_KEY,
+ JSON.stringify({ isConnected, handle }),
+ );
+ } else {
+ localStorage.removeItem(STORAGE_KEY);
+ }
+ set({ isConnected, handle });
+ } catch (error) {
+ console.error("Failed to check Twitter connection:", error);
+ localStorage.removeItem(STORAGE_KEY);
+ set({ isConnected: false, handle: null });
+ }
},
connect: async () => {
if (get().isConnecting) return;
@@ -22,12 +78,14 @@ const store = (set, get) => ({
set({ isConnecting: true, error: null });
await connectTwitter();
} catch (err) {
- set({ isConnecting: false, error: "Failed to connect to Twitter" });
+ const errorMessage = "Failed to connect to Twitter";
+ set({ isConnecting: false, error: errorMessage });
console.error("Twitter connection error:", err);
}
},
disconnect: async () => {
await disconnectTwitter();
+ localStorage.removeItem(STORAGE_KEY);
set({ isConnected: false, isConnecting: false, handle: null, error: null });
await get().checkConnection();
},
@@ -35,11 +93,17 @@ const store = (set, get) => ({
export const useTwitterStore = create(store);
+// Initialize store
+if (typeof window !== "undefined") {
+ useTwitterStore.getState().init();
+}
+
// Focused hooks
export const useTwitterConnection = () => {
const isConnected = useTwitterStore((state) => state.isConnected);
const isConnecting = useTwitterStore((state) => state.isConnecting);
const handle = useTwitterStore((state) => state.handle);
+ const error = useTwitterStore((state) => state.error);
const connect = useTwitterStore((state) => state.connect);
const disconnect = useTwitterStore((state) => state.disconnect);
const checkConnection = useTwitterStore((state) => state.checkConnection);
@@ -48,6 +112,7 @@ export const useTwitterConnection = () => {
isConnected,
isConnecting,
handle,
+ error,
connect,
disconnect,
checkConnection,
diff --git a/src/styles/globals.css b/src/styles/globals.css
index 6df1892..fd84ca4 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -2,34 +2,17 @@
@tailwind components;
@tailwind utilities;
-.blob {
- position: fixed;
- width: 500px;
- height: 500px;
- background: linear-gradient(
- to right,
- rgba(67, 56, 202, 0.4),
- rgba(168, 85, 247, 0.4),
- rgba(236, 72, 153, 0.4)
- );
- border-radius: 50%;
- filter: blur(100px);
- transition: all 0.3s ease;
- pointer-events: none;
- opacity: 0.7;
- mix-blend-mode: screen;
- animation: pulse 8s infinite;
-}
-
-@keyframes pulse {
- 0% {
- transform: scale(1);
+@layer components {
+ .base-component {
+ @apply border-2 border-gray-800 bg-white shadow-custom;
}
- 50% {
- transform: scale(1.1);
+
+ .base-component-hover {
+ @apply hover:bg-gray-100;
}
- 100% {
- transform: scale(1);
+
+ .base-component-ghost {
+ @apply border-0 shadow-none hover:bg-accent hover:text-accent-foreground;
}
}
diff --git a/tailwind.config.js b/tailwind.config.js
index 3d8b473..45b1abd 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -7,6 +7,9 @@ module.exports = {
],
theme: {
extend: {
+ boxShadow: {
+ custom: "2px 2px 0 rgba(0,0,0,1)",
+ },
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
@@ -56,5 +59,5 @@ module.exports = {
},
},
},
- plugins: [require("tailwindcss-animate")],
+ plugins: [require("tailwindcss-animate"), require("@tailwindcss/typography")],
};