Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Ipfs pfp upload #612

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
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
54 changes: 54 additions & 0 deletions components/ipfs/IPFSContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import React, { createContext, useContext, useMemo } from "react";
import IpfsService from "./IPFSService";

interface IpfsContextType {
ipfsService: IpfsService;
}

const IpfsContext = createContext<IpfsContextType | undefined>(undefined);

const getEnv = () => {
const apiEndpoint = process.env.IPFS_API_ENDPOINT;
const gatewayEndpoint = process.env.IPFS_GATEWAY_ENDPOINT;

if (!apiEndpoint || !gatewayEndpoint) {
throw new Error("IPFS_API_ENDPOINT and IPFS_GATEWAY_ENDPOINT must be set");
}

const mfsPath = process.env.IPFS_MFS_PATH;

return { apiEndpoint, gatewayEndpoint, mfsPath };
};

export const IpfsProvider: React.FC<{ children: React.ReactNode }> = ({
children,
}) => {
const { apiEndpoint, mfsPath } = getEnv();

const ipfsService = new IpfsService({
apiEndpoint,
mfsPath,
});

ipfsService.init();

const value = useMemo(() => ({ ipfsService }), [ipfsService]);

return <IpfsContext.Provider value={value}>{children}</IpfsContext.Provider>;
};

export const useIpfsService = (): IpfsService => {
const context = useContext(IpfsContext);
if (!context) {
throw new Error("useIpfsService must be used within an IpfsProvider");
}
return context.ipfsService;
};

export const resolveIpfsUrl = (url: string) => {
if (url.startsWith("ipfs://")) {
const { gatewayEndpoint } = getEnv();
return `${gatewayEndpoint}/ipfs/${url.slice(7)}`;
}
return url;
};
80 changes: 80 additions & 0 deletions components/ipfs/IPFSService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import axios from "axios";
import FormData from "form-data";

interface IpfsServiceConfig {
apiEndpoint: string;
mfsPath?: string;
}

class IpfsService {
private readonly apiEndpoint: string;
private readonly mfsPath?: string;
private readonly mfsEnabled: boolean;

constructor(config: IpfsServiceConfig) {
this.apiEndpoint = config.apiEndpoint;
this.mfsPath = config.mfsPath;
this.mfsEnabled = !!config.mfsPath;
}

async init() {
if (!this.mfsEnabled) return;

try {
await axios.post(
`${this.apiEndpoint}/api/v0/files/mkdir?arg=/${this.mfsPath}&parents=true`
);
} catch (error: any) {
console.error("Failed to configure MFS:", error.message);
throw error;
}
}

private createFormData(file: File): FormData {
const formData = new FormData();
formData.append("file", file);
return formData;
}

private async validateFileName(cid: string, file: File) {
if (!this.mfsEnabled) return;

const extension = file.name.split(".").pop();
const fileName = `${cid}.${extension}`;

try {
await axios.post(
`${this.apiEndpoint}/api/v0/files/stat?arg=/${this.mfsPath}/${fileName}`
);
console.log(`File ${fileName} already exists.`);
} catch (error: any) {
await axios.post(
`${this.apiEndpoint}/api/v0/files/cp?arg=/ipfs/${cid}&arg=/${this.mfsPath}/${fileName}`
);
console.log(`File added to MFS at ${this.mfsPath}/${fileName}`);
}
}

async addFile(file: File): Promise<string> {
try {
const formData = this.createFormData(file);

const addResponse = await axios.post(
`${this.apiEndpoint}/api/v0/add?pin=true`,
formData
);

const cid = addResponse.data.Hash;
console.log("File added to IPFS with CID:", cid);

await this.validateFileName(cid, file);

return cid;
} catch (error: any) {
console.error("Failed to add file to IPFS or MFS:", error.message);
throw error;
}
}
}

export default IpfsService;
86 changes: 66 additions & 20 deletions components/user/user-page-header/pfp/UserPageHeaderEditPfp.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,30 @@
import {FormEvent, useContext, useEffect, useRef, useState} from "react";
import {IProfileAndConsolidations} from "../../../../entities/IProfile";
import {useClickAway, useKeyPressEvent} from "react-use";
import UserSettingsImgSelectMeme, {MemeLite} from "../../settings/UserSettingsImgSelectMeme";
import { FormEvent, useContext, useEffect, useRef, useState } from "react";
import {
ApiCreateOrUpdateProfileRequest,
IProfileAndConsolidations,
} from "../../../../entities/IProfile";
import { useClickAway, useKeyPressEvent } from "react-use";
import UserSettingsImgSelectMeme, {
MemeLite,
} from "../../settings/UserSettingsImgSelectMeme";
import UserSettingsImgSelectFile from "../../settings/UserSettingsImgSelectFile";
import UserSettingsSave from "../../settings/UserSettingsSave";
import {AuthContext} from "../../../auth/Auth";
import {QueryKey, ReactQueryWrapperContext,} from "../../../react-query-wrapper/ReactQueryWrapper";
import {commonApiFetch, commonApiPostForm,} from "../../../../services/api/common-api";
import {useMutation, useQuery} from "@tanstack/react-query";
import {getScaledImageUri, ImageScale} from "../../../../helpers/image.helpers";
import { AuthContext } from "../../../auth/Auth";
import {
QueryKey,
ReactQueryWrapperContext,
} from "../../../react-query-wrapper/ReactQueryWrapper";
import {
commonApiFetch,
commonApiPost,
commonApiPostForm,
} from "../../../../services/api/common-api";
import { useMutation, useQuery } from "@tanstack/react-query";
import {
getScaledImageUri,
ImageScale,
} from "../../../../helpers/image.helpers";
import { useIpfsService } from "../../../ipfs/IPFSContext";

export default function UserPageHeaderEditPfp({
profile,
Expand All @@ -21,6 +37,8 @@ export default function UserPageHeaderEditPfp({
useClickAway(modalRef, onClose);
useKeyPressEvent("Escape", onClose);

const ipfsService = useIpfsService();

const { setToast, requestAuth } = useContext(AuthContext);
const { onProfileEdit } = useContext(ReactQueryWrapperContext);

Expand All @@ -40,7 +58,9 @@ export default function UserPageHeaderEditPfp({
});

const [imageToShow, setImageToShow] = useState<string | null>(
(profile.profile?.pfp_url ? getScaledImageUri(profile.profile.pfp_url, ImageScale.W_200_H_200) : null)
profile.profile?.pfp_url
? getScaledImageUri(profile.profile.pfp_url, ImageScale.W_200_H_200)
: null
);

const [selectedMeme, setSelectedMeme] = useState<MemeLite | null>(null);
Expand Down Expand Up @@ -69,18 +89,45 @@ export default function UserPageHeaderEditPfp({
const updatePfp = useMutation({
mutationFn: async (body: FormData) => {
setSaving(true);
return await commonApiPostForm<{ pfp_url: string }>({
endpoint: `profiles/${profile.input_identity}/pfp`,
body: body,
});
const pfp = body.get("pfp");
if (pfp) {
if (!profile.profile?.classification) {
return;
}
const cid = await ipfsService.addFile(pfp as File);
const ipfs = `ipfs://${cid}`;
const ipfsBody: ApiCreateOrUpdateProfileRequest = {
handle: profile.profile?.handle,
classification: profile.profile?.classification,
pfp_url: ipfs,
};
if (profile.profile?.banner_1) {
ipfsBody.banner_1 = ipfs;
}
if (profile.profile?.banner_2) {
ipfsBody.banner_2 = ipfs;
}
const response = await commonApiPost<
ApiCreateOrUpdateProfileRequest,
IProfileAndConsolidations
>({
endpoint: `profiles`,
body: ipfsBody,
});
return response.profile?.pfp_url;
} else {
const response = await commonApiPostForm<{ pfp_url: string }>({
endpoint: `profiles/${profile.input_identity}/pfp`,
body: body,
});
return response.pfp_url;
}
},
onSuccess: (response) => {
onSuccess: (pfp_url) => {
onProfileEdit({
profile: {
...profile,
profile: profile.profile
? { ...profile.profile, pfp_url: response.pfp_url }
: null,
profile: profile.profile ? { ...profile.profile, pfp_url } : null,
},
previousProfile: null,
});
Expand Down Expand Up @@ -139,8 +186,7 @@ export default function UserPageHeaderEditPfp({
<div className="tw-flex tw-min-h-full tw-items-end tw-justify-center tw-text-center sm:tw-items-center tw-p-2 lg:tw-p-0">
<div
ref={modalRef}
className={`sm:tw-max-w-3xl md:tw-max-w-2xl tw-relative tw-w-full tw-transform tw-rounded-xl tw-bg-iron-950 tw-text-left tw-shadow-xl tw-transition-all tw-duration-500 sm:tw-w-full tw-p-6 lg:tw-p-8`}
>
className={`sm:tw-max-w-3xl md:tw-max-w-2xl tw-relative tw-w-full tw-transform tw-rounded-xl tw-bg-iron-950 tw-text-left tw-shadow-xl tw-transition-all tw-duration-500 sm:tw-w-full tw-p-6 lg:tw-p-8`}>
<form onSubmit={onSubmit}>
<UserSettingsImgSelectMeme
memes={memes ?? []}
Expand Down
2 changes: 0 additions & 2 deletions helpers/image.helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,5 +41,3 @@ export function getScaledImageUri(url: string, scale: ImageScale): string {
}
return url;
}

// https://d3lqz0a4bldqgf.cloudfront.net/waves/author_7c6be24d-87b2-11ee-9661-02424e2c14ad/50acc51a-d4f1-4b63-b549-1a721b2be0ae.webp
3 changes: 3 additions & 0 deletions next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,9 @@ const nextConfig = {
VERSION: VERSION,
NEXTGEN_CHAIN_ID: process.env.NEXTGEN_CHAIN_ID,
MOBILE_APP_SCHEME: process.env.MOBILE_APP_SCHEME,
IPFS_API_ENDPOINT: process.env.IPFS_API_ENDPOINT,
IPFS_GATEWAY_ENDPOINT: process.env.IPFS_GATEWAY_ENDPOINT,
IPFS_MFS_PATH: process.env.IPFS_MFS_PATH,
},
async generateBuildId() {
return VERSION;
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

51 changes: 40 additions & 11 deletions pages/_app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ import { NotificationsProvider } from "../components/notifications/Notifications
import Footer from "../components/footer/Footer";
import { useRouter } from "next/router";
import { SeizeConnectProvider } from "../components/auth/SeizeConnectContext";
import { IpfsProvider, resolveIpfsUrl } from "../components/ipfs/IPFSContext";
import { EULAConsentProvider } from "../components/eula/EULAConsentContext";

library.add(
Expand Down Expand Up @@ -279,6 +280,32 @@ export default function App({ Component, ...rest }: AppPropsWithLayout) {
}
}, []);

const updateImagesSrc = () => {
const elementsWithSrc = document.querySelectorAll("[src]");
Array.from(elementsWithSrc).forEach((el) => {
const src = el.getAttribute("src")!;
const newSrc = resolveIpfsUrl(src);
el.setAttribute("src", newSrc);
});
};

useEffect(() => {
updateImagesSrc();

const observer = new MutationObserver(() => {
updateImagesSrc();
});

observer.observe(document.body, {
childList: true,
subtree: true,
});

return () => {
observer.disconnect();
};
}, []);

return (
<QueryClientProvider client={queryClient}>
<Provider store={store}>
Expand All @@ -292,17 +319,19 @@ export default function App({ Component, ...rest }: AppPropsWithLayout) {
)}
<WagmiProvider config={wagmiConfig}>
<ReactQueryWrapper>
<SeizeConnectProvider>
<Auth>
<NotificationsProvider>
<CookieConsentProvider>
<EULAConsentProvider>
{getLayout(<Component {...props} />)}
</EULAConsentProvider>
</CookieConsentProvider>
</NotificationsProvider>
</Auth>
</SeizeConnectProvider>
<IpfsProvider>
<SeizeConnectProvider>
<Auth>
<NotificationsProvider>
<CookieConsentProvider>
<EULAConsentProvider>
{getLayout(<Component {...props} />)}
</EULAConsentProvider>
</CookieConsentProvider>
</NotificationsProvider>
</Auth>
</SeizeConnectProvider>
</IpfsProvider>
</ReactQueryWrapper>
{!hideFooter && <Footer />}
</WagmiProvider>
Expand Down
Loading