From 3ecfbb481ab42f1cd13a292e519b86dbf078ec20 Mon Sep 17 00:00:00 2001 From: odzioo123 <1konrad.sadowski@gmail.com> Date: Tue, 21 Oct 2025 20:31:52 +0200 Subject: [PATCH 01/11] Attempt to transfer twitter branch changes to develop_test. Backend ready Frontend work in progress (TODO CreateNewTaskModal, DiscordTask.tsx, TwitterTask.tsx) --- Cargo.lock | 71 +- Cargo.toml | 4 +- .../src/canisters/atlasSpace/api.ts | 85 +- .../src/canisters/atlasSpace/tasks.ts | 35 +- .../src/components/DiscordButton.tsx | 9 - .../Integrations/DiscordCallback.tsx | 10 +- .../Integrations/TwitterCallback.tsx | 28 + .../Integrations/discord}/discord.ts | 9 +- .../Integrations/discord/inviteLink.ts | 37 + .../components/Integrations/discord/types.ts | 17 + .../Integrations/twitter/twitter.ts | 104 ++ .../src/components/Task/TaskRenderer.tsx | 62 ++ .../src/components/Task/index.tsx | 38 +- .../src/components/Task/tasks/DiscordTask.tsx | 175 ++++ .../src/components/Task/tasks/GenericTask.tsx | 13 +- .../src/components/Task/tasks/TwitterTask.tsx | 202 ++++ .../src/components/Task/tasks/taskRegistry.ts | 9 + .../src/hooks/useDiscordAdmin.ts | 96 ++ .../src/hooks/useDiscordAuth.ts | 63 ++ .../src/hooks/useDiscordUser.ts | 56 ++ .../src/hooks/useTwitterAuth.ts | 87 ++ .../src/modals/tasks/DiscordTask.tsx | 248 +++++ .../src/modals/tasks/TwitterTask.tsx | 58 ++ src/atlas_frontend/src/router/index.tsx | 3 + src/atlas_frontend/src/router/paths.ts | 1 + .../src/store/slices/userSlice.ts | 16 +- src/atlas_frontend/src/utils/discord.ts | 59 ++ src/atlas_frontend/src/utils/taskMapper.ts | 72 ++ src/atlas_frontend/src/utils/tasks.ts | 59 +- src/atlas_space/Cargo.toml | 7 +- src/atlas_space/atlas_space.did | 34 +- src/atlas_space/src/methods/update.rs | 913 ++++++++++-------- src/atlas_space/src/tasks/submission.rs | 7 + src/atlas_space/src/tasks/task_types.rs | 311 ++++-- src/declarations/atlas_space/atlas_space.did | 34 +- .../atlas_space/atlas_space.did.d.ts | 45 +- .../atlas_space/atlas_space.did.js | 34 +- 37 files changed, 2516 insertions(+), 595 deletions(-) delete mode 100644 src/atlas_frontend/src/components/DiscordButton.tsx create mode 100644 src/atlas_frontend/src/components/Integrations/TwitterCallback.tsx rename src/atlas_frontend/src/{integrations => components/Integrations/discord}/discord.ts (95%) create mode 100644 src/atlas_frontend/src/components/Integrations/discord/inviteLink.ts create mode 100644 src/atlas_frontend/src/components/Integrations/discord/types.ts create mode 100644 src/atlas_frontend/src/components/Integrations/twitter/twitter.ts create mode 100644 src/atlas_frontend/src/components/Task/TaskRenderer.tsx create mode 100644 src/atlas_frontend/src/components/Task/tasks/DiscordTask.tsx create mode 100644 src/atlas_frontend/src/components/Task/tasks/TwitterTask.tsx create mode 100644 src/atlas_frontend/src/components/Task/tasks/taskRegistry.ts create mode 100644 src/atlas_frontend/src/hooks/useDiscordAdmin.ts create mode 100644 src/atlas_frontend/src/hooks/useDiscordAuth.ts create mode 100644 src/atlas_frontend/src/hooks/useDiscordUser.ts create mode 100644 src/atlas_frontend/src/hooks/useTwitterAuth.ts create mode 100644 src/atlas_frontend/src/modals/tasks/DiscordTask.tsx create mode 100644 src/atlas_frontend/src/modals/tasks/TwitterTask.tsx create mode 100644 src/atlas_frontend/src/utils/discord.ts create mode 100644 src/atlas_frontend/src/utils/taskMapper.ts diff --git a/Cargo.lock b/Cargo.lock index 843e97e..ec8d9f6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -35,18 +35,21 @@ dependencies = [ name = "atlas_space" version = "0.1.0" dependencies = [ + "base64", "candid", "ethnum", "hex", "ic-cdk", "ic-cdk-timers", "ic-ledger-types", + "ic-management-canister-types", "ic-stable-structures", "icrc-ledger-types", "minicbor 0.26.5", "minicbor-derive 0.16.2", "num-bigint", "serde", + "serde_json", "sha2", "shared", "slotmap", @@ -65,6 +68,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa" +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "binread" version = "2.2.0" @@ -105,9 +114,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "candid" -version = "0.10.14" +version = "0.10.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9d90f5a1426d0489283a0bd5da9ed406fb3e69597e0d823dcb88a1965bb58d2" +checksum = "8037a01ec09d6c06883a38bad4f47b8d06158ad360b841e0ae5707c9884dfaf6" dependencies = [ "anyhow", "binread", @@ -128,9 +137,9 @@ dependencies = [ [[package]] name = "candid_derive" -version = "0.6.6" +version = "0.10.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3de398570c386726e7a59d9887b68763c481477f9a043fb998a2e09d428df1a9" +checksum = "fb45f4d5eff3805598ee633dd80f8afb306c023249d34b5b7dfdc2080ea1df2e" dependencies = [ "lazy_static", "proc-macro2", @@ -375,9 +384,9 @@ dependencies = [ [[package]] name = "ic-cdk" -version = "0.18.5" +version = "0.18.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db9cc3e0e86ee12504c749fa33793014f1f4d6956a8a70e4db595169c5f6ac26" +checksum = "4efb278f5d3ef033b3eed7f01f1096eaf67701896aa5ef69f5eddf5a84833dc0" dependencies = [ "candid", "ic-cdk-executor", @@ -393,18 +402,19 @@ dependencies = [ [[package]] name = "ic-cdk-executor" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15948808e3e7b50749fe50838df77fccaf048c8af2c26884ff5c8f787c29787a" +checksum = "99f4ee8930fd2e491177e2eb7fff53ee1c407c13b9582bdc7d6920cf83109a2d" dependencies = [ + "ic0", "slotmap", ] [[package]] name = "ic-cdk-macros" -version = "0.18.5" +version = "0.18.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b190cace2b141a5801252115bdc27397d47f086c928af3e917ce1da81b17e3cd" +checksum = "7eb14c5d691cc9d72bb95459b4761e3a4b3444b85a63d17555d5ddd782969a1e" dependencies = [ "candid", "darling", @@ -456,9 +466,9 @@ dependencies = [ [[package]] name = "ic-management-canister-types" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95f3af3543f6d0cbdecd2dcdfd4737ada2bd42d935cc787eec22090c96492c76" +checksum = "ea7e5b8a0f7c3b320d9450ac950547db4f24a31601b5d398f9680b64427455d2" dependencies = [ "candid", "serde", @@ -724,12 +734,19 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a0d197bd2c9dc6e53b84da9556a69ba4cdfab8619eb41a8bd1cc2027a0f6b1d" +[[package]] +name = "ryu" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d3b2b1366ec20994f1fd18c3c594f05c5dd4bc44d8bb0c1c632c8d6829481f" + [[package]] name = "serde" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e2c6ed6606019b4e29e69dbaba95b11854410e5347d525002456dbbb786b6" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ + "serde_core", "serde_derive", ] @@ -742,17 +759,39 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + [[package]] name = "serde_derive" -version = "1.0.219" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b0276cf7f2c73365f7157c8123c21cd9a50fbbd844757af28ca1f5925fc2a00" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", "syn 2.0.104", ] +[[package]] +name = "serde_json" +version = "1.0.145" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", + "serde_core", +] + [[package]] name = "sha2" version = "0.10.9" diff --git a/Cargo.toml b/Cargo.toml index 9fcf14b..4133961 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,8 +4,10 @@ resolver = "2" [workspace.dependencies] candid = "0.10" -ic-cdk = "0.18.5" +ic-cdk = "0.18.7" +ic-management-canister-types = "0.3.1" serde = "1.0.219" +serde_json = "1.0.140" hex = "0.4.3" ic-stable-structures = "0.6.8" minicbor = { version = "0.26.4", features = ["alloc", "derive"] } diff --git a/src/atlas_frontend/src/canisters/atlasSpace/api.ts b/src/atlas_frontend/src/canisters/atlasSpace/api.ts index a18d9c8..3a78bc5 100644 --- a/src/atlas_frontend/src/canisters/atlasSpace/api.ts +++ b/src/atlas_frontend/src/canisters/atlasSpace/api.ts @@ -16,17 +16,15 @@ import type { Dispatch } from "react"; import type { UnknownAction } from "@reduxjs/toolkit"; import type { Principal } from "@dfinity/principal"; import type { ExternalLinks } from "./types.js"; +import type { DiscordTaskContent, GenericTaskContent, TwitterTaskContent } from "../../utils/taskMapper.js"; +import type { DiscordGuild, DiscordInviteApiResponse } from "../../components/Integrations/discord/types.js"; +import { getUserGuilds } from "../../components/Integrations/discord/userGuilds.js"; export interface ExpiredTask extends Task { expired: true; } +import { validateDiscordInvite as validateInvite } from "../../components/Integrations/discord/inviteLink.js"; -interface CreateSubtaskArg { - task_type: string; - title: string; - description: string; - allow_resubmit: boolean; - answer_format: AnswerFormat -} +type CreateSubtaskArg = GenericTaskContent | DiscordTaskContent | TwitterTaskContent; interface GetAtlasSpaceArgs { unAuthAtlasSpace: ActorSubclass<_SERVICE>; @@ -92,14 +90,36 @@ export const createNewTask = async ({ startTime, endTime, }: CreateNewSpaceTaskArgs) => { - const transformedTasks: TaskContent[] = tasks.map((arg) => ({ - TitleAndDescription: { - task_title: arg.title, - task_description: arg.description, - allow_resubmit: arg.allow_resubmit, - answer_format: arg.answer_format - }, - })); + const transformedTasks: TaskContent[] = tasks.map((arg) => { + if (arg.task_type === "discord") { + return { + DiscordTask: { + task_title: arg.title, + task_description: arg.description, + guild_id: arg.guild_id, + invite_link: arg.invite_link, + allow_resubmit: arg.allow_resubmit, + }, + }; + } else if (arg.task_type === "twitter") { + return { + TwitterTask: { + task_title: arg.title, + task_description: arg.description, + allow_resubmit: arg.allow_resubmit, + }, + }; + } else { + return { + TitleAndDescription: { + task_title: arg.title, + task_description: arg.description, + allow_resubmit: arg.allow_resubmit, + answer_format: arg.answer_format, + }, + }; + } + }); const call = authAtlasSpaceActor.create_task({ task_title: taskTitle, @@ -436,3 +456,38 @@ export const deleteClosedTask = async ({ errMsg: "Failed to delete closed task", }); }; + +export const getDiscordGuilds = async ( + accessToken: string +): Promise => { + return await getUserGuilds(accessToken); +}; + +export const validateDiscordInvite = async ( + inviteCode: string, + expectedGuildId: string +): Promise => { + return await validateInvite(inviteCode, expectedGuildId); +}; + +interface ExchangeCodeForTokenArgs { + authAtlasSpace: ActorSubclass<_SERVICE>; + code: string; + codeVerifier: string; +} + +export const exchange_code_for_token = async ({ + authAtlasSpace, + code, + codeVerifier +}: ExchangeCodeForTokenArgs) => { + const call = authAtlasSpace.exchange_code_for_token( + code, + codeVerifier + ); + + return unwrapCall({ + call, + errMsg: "Failed to exchange Twitter code for token", + }); +}; \ No newline at end of file diff --git a/src/atlas_frontend/src/canisters/atlasSpace/tasks.ts b/src/atlas_frontend/src/canisters/atlasSpace/tasks.ts index ae0e7f5..a1a6bb8 100644 --- a/src/atlas_frontend/src/canisters/atlasSpace/tasks.ts +++ b/src/atlas_frontend/src/canisters/atlasSpace/tasks.ts @@ -1,27 +1,26 @@ +import type { Principal } from "@dfinity/principal"; import type { + SubmissionData, SubmissionState, TaskType, } from "../../../../declarations/atlas_space/atlas_space.did"; import type { UserSubmissionsData } from "./types"; -export const getUsersSubmissions = (tasks: TaskType[]) => { - const data = tasks.reduce((acc, task, index) => { - if ("GenericTask" in task) { - const genericTask = task.GenericTask; - genericTask.submission.forEach(([principal, submissionData]) => { - const principalText = principal.toText(); - if (!acc[principalText]) { - acc[principalText] = {}; - } - if (!acc[principalText][`${index}`]) { - acc[principalText][`${index}`] = { - submissionData, - taskType: "GenericTask", - }; - } - }); - return acc; - } +export const getUsersSubmissions = (tasks: { [key: string]: TaskType }) => { + const data = Object.entries(tasks).reduce((acc, [subtaskIdStr, task]) => { + const foundType = Object.keys(task)[0] as keyof TaskType; + const taskData = task[foundType] as { submission: [Principal, SubmissionData][] }; + + taskData.submission.forEach(([principal, submissionData]) => { + const principalText = principal.toText(); + if (!acc[principalText]) { + acc[principalText] = {}; + } + acc[principalText][subtaskIdStr] = { + submissionData, + taskType: foundType, + }; + }); return acc; }, {} as UserSubmissionsData); diff --git a/src/atlas_frontend/src/components/DiscordButton.tsx b/src/atlas_frontend/src/components/DiscordButton.tsx deleted file mode 100644 index d6a6946..0000000 --- a/src/atlas_frontend/src/components/DiscordButton.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from "react" -import Button from "./Shared/Button.tsx" - - -const DiscordButton = () => { - -} - -export default DiscordButton \ No newline at end of file diff --git a/src/atlas_frontend/src/components/Integrations/DiscordCallback.tsx b/src/atlas_frontend/src/components/Integrations/DiscordCallback.tsx index e37ea36..eb745d7 100644 --- a/src/atlas_frontend/src/components/Integrations/DiscordCallback.tsx +++ b/src/atlas_frontend/src/components/Integrations/DiscordCallback.tsx @@ -1,21 +1,13 @@ import React, { useEffect } from "react"; -import { useAuth } from "@nfid/identitykit/react"; const DiscordCallback = () => { - const { user } = useAuth(); useEffect(() => { const query = new URLSearchParams(window.location.hash.substring(1)); - const tokenType = query.get("token_type"); const accessToken = query.get("access_token"); - const state = query.get("state"); - const expiresIn = query.get("expires_in"); if ( - !tokenType || !accessToken || - !expiresIn || - state === user?.principal.toString() || !window.opener ) { return @@ -23,7 +15,7 @@ const DiscordCallback = () => { try { window.opener.postMessage( - { tokenType, accessToken, state, expiresIn }, + { accessToken }, window.location.origin ); } catch (err) { diff --git a/src/atlas_frontend/src/components/Integrations/TwitterCallback.tsx b/src/atlas_frontend/src/components/Integrations/TwitterCallback.tsx new file mode 100644 index 0000000..5e0e042 --- /dev/null +++ b/src/atlas_frontend/src/components/Integrations/TwitterCallback.tsx @@ -0,0 +1,28 @@ +import { useEffect } from "react"; + +const TwitterCallback = () => { + useEffect(() => { + const qs = new URLSearchParams(window.location.search); + const payload = { + type: "x-oauth2-callback", + code: qs.get("code") || undefined, + state: qs.get("state") || undefined, + error: qs.get("error") || undefined, + error_description: qs.get("error_description") || undefined, + }; + + try { + if (window.opener && window.opener !== window) { + window.opener.postMessage(payload, window.location.origin); + setTimeout(() => window.close(), 50); + } else { + sessionStorage.setItem("x_oauth_payload", JSON.stringify(payload)); + window.location.replace("/"); + } + } catch { + window.location.replace("/"); + } + }, []); + return null; +}; +export default TwitterCallback; \ No newline at end of file diff --git a/src/atlas_frontend/src/integrations/discord.ts b/src/atlas_frontend/src/components/Integrations/discord/discord.ts similarity index 95% rename from src/atlas_frontend/src/integrations/discord.ts rename to src/atlas_frontend/src/components/Integrations/discord/discord.ts index 9e2ab32..9b544d4 100644 --- a/src/atlas_frontend/src/integrations/discord.ts +++ b/src/atlas_frontend/src/components/Integrations/discord/discord.ts @@ -1,5 +1,5 @@ import axios from "axios"; -import { DISCORD_CALLBACK_PATH } from "../router/paths"; +import { DISCORD_CALLBACK_PATH } from "../../../router/paths"; export interface UserData { accent_color: null | string; @@ -21,12 +21,10 @@ export interface UserData { username: string; verified: boolean; } - export const getOAuth2URL = (stateData?: string) => { const discordBase = "https://discord.com"; const path = "/oauth2/authorize"; const url = new URL(path, discordBase); - url.searchParams.set("client_id", import.meta.env.PUBLIC_DISCORD_CLIENT_ID); url.searchParams.set( "redirect_uri", @@ -35,10 +33,8 @@ export const getOAuth2URL = (stateData?: string) => { url.searchParams.set("response_type", "token"); url.searchParams.set("scope", "identify"); if (stateData) url.searchParams.set("state", stateData); - return url.toString(); }; - export const getUserData = async (token: string) => { const { data } = await axios.get( "https://discord.com/api/users/@me", @@ -48,6 +44,5 @@ export const getUserData = async (token: string) => { }, } ); - return data; -}; +}; \ No newline at end of file diff --git a/src/atlas_frontend/src/components/Integrations/discord/inviteLink.ts b/src/atlas_frontend/src/components/Integrations/discord/inviteLink.ts new file mode 100644 index 0000000..0525303 --- /dev/null +++ b/src/atlas_frontend/src/components/Integrations/discord/inviteLink.ts @@ -0,0 +1,37 @@ +import type { DiscordInviteApiResponse } from "./types"; +import axios, { AxiosError } from "axios"; + +export const validateDiscordInvite = async ( + inviteCode: string, + expectedGuildId: string +): Promise => { + if (!inviteCode || inviteCode.includes("/")) { + throw new Error("Invalid Discord invite code format."); + } + + const url = `https://discord.com/api/v10/invites/${inviteCode}?with_counts=false`; + + let response; + try { + response = await axios.get(url); + } catch (err: unknown) { + throw new Error( + `The invite link is invalid.` + ); + + } + + const inviteData: DiscordInviteApiResponse = response.data; + + const guild = inviteData.guild; + + if (guild && guild.id === expectedGuildId) { + return inviteData; + } else if (guild) { + throw new Error( + `Invite is for a different server` + ); + } else { + throw new Error("Invite is not for a valid server."); + } +}; \ No newline at end of file diff --git a/src/atlas_frontend/src/components/Integrations/discord/types.ts b/src/atlas_frontend/src/components/Integrations/discord/types.ts new file mode 100644 index 0000000..8201193 --- /dev/null +++ b/src/atlas_frontend/src/components/Integrations/discord/types.ts @@ -0,0 +1,17 @@ +export interface DiscordGuild { + id: string; + name: string; + icon: string | null; + owner: boolean; + permissions: string; +} + +export interface DiscordGuildInfo { + id: string; + name: string; +} + +export interface DiscordInviteApiResponse { + guild: DiscordGuildInfo | null; + expires_at: string | null; +} \ No newline at end of file diff --git a/src/atlas_frontend/src/components/Integrations/twitter/twitter.ts b/src/atlas_frontend/src/components/Integrations/twitter/twitter.ts new file mode 100644 index 0000000..5bdc818 --- /dev/null +++ b/src/atlas_frontend/src/components/Integrations/twitter/twitter.ts @@ -0,0 +1,104 @@ +type PopupOptions = { + clientId: string; + redirectUri: string; + scope: string; +}; + +type AuthResult = { + code: string; + state: string; + codeVerifier: string; +}; + +const AUTHORIZE_URL = "https://x.com/i/oauth2/authorize"; + + +function randomState(bytes = 24) { + const arr = new Uint8Array(bytes); + crypto.getRandomValues(arr); + return Array.from(arr, (b) => ("0" + b.toString(16)).slice(-2)).join(""); +} + + +async function buildAuthorizeUrl(opts: PopupOptions, state: string) { +const codeVerifier = Array.from(crypto.getRandomValues(new Uint8Array(64)), b => ("0"+b.toString(16)).slice(-2)).join(""); + console.log("codeVerifier: ", codeVerifier); +const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(codeVerifier)); +const codeChallenge = btoa(String.fromCharCode(...new Uint8Array(digest))).replace(/\+/g,"-").replace(/\//g,"_").replace(/=+$/,""); + const url = new URL(AUTHORIZE_URL); + url.searchParams.set("response_type", "code"); + url.searchParams.set("client_id", opts.clientId); + url.searchParams.set("redirect_uri", opts.redirectUri); + url.searchParams.set("scope", opts.scope); + url.searchParams.set("state", state); + url.searchParams.set("code_challenge", codeChallenge); + url.searchParams.set("code_challenge_method", "S256"); + return { url: url.toString(), codeVerifier }; +} + +function openCenteredPopup(url: string, title: string, width = 520, height = 720): Window | null { + const dualScreenLeft = window.screenLeft ?? window.screenX ?? 0; + const dualScreenTop = window.screenTop ?? window.screenY ?? 0; + const w = window.innerWidth ?? document.documentElement.clientWidth ?? screen.width; + const h = window.innerHeight ?? document.documentElement.clientHeight ?? screen.height; + const left = Math.max(0, w / 2 - width / 2) + dualScreenLeft; + const top = Math.max(0, h / 2 - height / 2) + dualScreenTop; + const features = [ + "scrollbars=yes", + "resizable=yes", + `width=${width}`, + `height=${height}`, + `top=${top}`, + `left=${left}`, + ].join(","); + return window.open(url, title, features); +} + +export async function openTwitterLoginPopup(opts: PopupOptions): Promise { + if (!opts.clientId || !opts.redirectUri || !opts.scope) { + throw new Error("Required parameters are missing: clientId, redirectUri, scope."); + } + + const state = randomState(); + + const {url: authUrl, codeVerifier } = await buildAuthorizeUrl(opts, state); + const popup = openCenteredPopup(await authUrl, "Sign in with X"); + if (!popup) throw new Error("Failed to open login window."); + + const expectedOrigin = new URL(opts.redirectUri).origin; + + return new Promise((resolve, reject) => { + const onMessage = (ev: MessageEvent) => { + if (ev.origin !== expectedOrigin) return; + const data = ev.data as { type?: string; code?: string; state?: string; error?: string; error_description?: string }; + if (data?.type !== "x-oauth2-callback") return; + + cleanup(); + + if (data.error) { + reject(new Error(data.error_description || data.error)); + return; + } + if (!data.code || !data.state) { + reject(new Error("Missing code/state in callback answer.")); + return; + } + resolve({ code: data.code, state: data.state, codeVerifier }); + }; + + const checkClosed = setInterval(() => { + if (popup.closed) { + cleanup(); + reject(new Error("The login window has been closed.")); + } + }, 400); + + function cleanup() { + clearInterval(checkClosed); + window.removeEventListener("message", onMessage); + try { if (popup) popup.close(); } catch {} + } + + window.addEventListener("message", onMessage); + }); +} \ No newline at end of file diff --git a/src/atlas_frontend/src/components/Task/TaskRenderer.tsx b/src/atlas_frontend/src/components/Task/TaskRenderer.tsx new file mode 100644 index 0000000..b7d7a00 --- /dev/null +++ b/src/atlas_frontend/src/components/Task/TaskRenderer.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { taskRegistry } from './tasks/taskRegistry'; +import type { TaskType } from "../../../../declarations/atlas_space/atlas_space.did"; +import type { Principal } from '@dfinity/principal'; +import type { ActorSubclass } from '@dfinity/agent'; +import type { _SERVICE } from '../../../../declarations/atlas_space/atlas_space.did'; + +interface TaskRendererProps { + task: [number, TaskType]; + spacePrincipal: Principal; + taskId: string; + unAuthAtlasSpace: ActorSubclass<_SERVICE> | null; + isUserInHub: boolean; + authAtlasSpace: ActorSubclass<_SERVICE> | null; + disabled?: boolean; + isAdmin?: boolean; +} + +type GenericTaskType = Extract["GenericTask"]; +type DiscordTaskType = Extract["DiscordTask"]; +type TwitterTaskType = Extract["TwitterTask"]; + +const TaskRenderer: React.FC = ({ task, ...props }) => { + const [subtaskId, taskData] = task; + + if ("GenericTask" in taskData) { + const GenericTaskComponent = taskRegistry.GenericTask; + return ( + + ); + } + + if ("DiscordTask" in taskData) { + const DiscordTaskComponent = taskRegistry.DiscordTask; + return ( + + ); + } + + if ("TwitterTask" in taskData) { + const TwitterTaskComponent = taskRegistry.TwitterTask; + return ( + + ); + } + + return null; +}; + +export default TaskRenderer; \ No newline at end of file diff --git a/src/atlas_frontend/src/components/Task/index.tsx b/src/atlas_frontend/src/components/Task/index.tsx index 5c117ec..2bb8d8d 100644 --- a/src/atlas_frontend/src/components/Task/index.tsx +++ b/src/atlas_frontend/src/components/Task/index.tsx @@ -44,13 +44,14 @@ import { nowInSeconds, } from "../../utils/date"; import Calendar from "../../icons/calendar.svg?react"; -import { getTaskType } from "../../utils/tasks"; import InfoBox from "../Space/TaskCard/InfoBox"; import { runWithLoading } from "../../utils/loading"; import { RiWalletFill } from "react-icons/ri"; import { formatUnits } from "ethers"; import { DECIMALS } from "../../canisters/ckUsdcLedger/constans"; import { FaAngleLeft, FaAngleRight } from "react-icons/fa6"; +import TaskRenderer from "./TaskRenderer"; +import { getTaskType } from "../../utils/tasks"; const Task = () => { const { spacePrincipal, taskId } = useParams(); @@ -154,7 +155,11 @@ const Task = () => { const taskClosed = isClosedTask(currentTask); const usersSubmissions = currentTask?.tasks - ? getUsersSubmissions(currentTask.tasks) + ? getUsersSubmissions( + Object.fromEntries( + currentTask.tasks.map((task, idx) => [idx.toString(), task]) + ) + ) : new UserSubmissions({}); if (!user?.principal) return <>; @@ -445,20 +450,21 @@ const Task = () => {
- {currentTask.tasks.map((task, key) => ( - - ))} + {Object.entries(currentTask.tasks).map( + ([subtaskId, taskData], i) => ( + + ) + )}
diff --git a/src/atlas_frontend/src/components/Task/tasks/DiscordTask.tsx b/src/atlas_frontend/src/components/Task/tasks/DiscordTask.tsx new file mode 100644 index 0000000..0f911dc --- /dev/null +++ b/src/atlas_frontend/src/components/Task/tasks/DiscordTask.tsx @@ -0,0 +1,175 @@ +import React from "react"; +import type { + _SERVICE, + TaskType, +} from "../../../../../declarations/atlas_space/atlas_space.did"; +import type { Principal } from "@dfinity/principal"; +import type { ActorSubclass } from "@dfinity/agent"; +import { useDispatch } from "react-redux"; +import { useAuth } from "@nfid/identitykit/react"; +import toast from "react-hot-toast"; +import { submitSubtaskSubmission, getSpaceTasks, getRejectionInfo} from "../../../canisters/atlasSpace/api"; +import { useAuthAtlasSpaceActor } from "../../../hooks/identityKit"; +import Button from "../../Shared/Button"; +import { useDiscordUser } from "../../../hooks/useDiscordUser"; +import { useDiscordAuth } from "../../../hooks/useDiscordAuth"; +import { getErrorWithInfoToast } from "../../../utils/errors"; +import { runWithLoading } from "../../../utils/loading"; + +type DiscordTaskType = Extract['DiscordTask']; + +interface DiscordTaskProps { + discordTask: DiscordTaskType; + spacePrincipal: Principal; + taskId: string; + subtaskId: number; + unAuthAtlasSpace: ActorSubclass<_SERVICE> | null; + isUserInHub: boolean; +} + +const DiscordTask = ({ + discordTask, + spacePrincipal, + taskId, + subtaskId, + unAuthAtlasSpace, + isUserInHub, +}: DiscordTaskProps) => { + const dispatch = useDispatch(); + const { user, connect } = useAuth(); + const authAtlasSpace = useAuthAtlasSpaceActor(spacePrincipal); + + const { signIn, accessToken } = useDiscordAuth(); + const { isJoined, setIsJoined, joinServer, checkGuildMembership, discordUser } = useDiscordUser(spacePrincipal); + + const handleSubmit = async () => { + const requiredGuildId = 'DiscordTask' in discordTask.task_content && discordTask.task_content.DiscordTask.guild_id; + + if (!requiredGuildId) { + toast.error("Guild ID not found in task details."); + return; + } + + const isMember = await checkGuildMembership(requiredGuildId); + + if (isMember && authAtlasSpace) { + await runWithLoading(async () => { + const call = submitSubtaskSubmission({ + authAtlasSpace, + taskId: BigInt(taskId), + subtaskId: BigInt(subtaskId), + submission: { Discord: { username: discordUser?.username ?? "", user_id: BigInt(discordUser?.id ?? "0") } }, + }); + + await toast.promise(call, { + loading: "Submitting response...", + success: "Submitted response.", + error: getErrorWithInfoToast("Failed to submit response."), + }); + + if (unAuthAtlasSpace) { + getSpaceTasks({ + spaceId: spacePrincipal.toString(), + unAuthAtlasSpace, + dispatch, + }); + } + }, dispatch); + } + }; + + const [, submissionData] = user?.principal + ? (discordTask.submission.find( + ([principal]) => principal.toString() === user.principal.toString() + ) ?? []) + : []; + + const currentSubmissionState = submissionData?.state + ? Object.keys(submissionData?.state)[0] + : null; + + const canSubmit = user && isUserInHub && ( + currentSubmissionState === null || + (currentSubmissionState === "Rejected" && ("DiscordTask" in discordTask.task_content + ? discordTask.task_content.DiscordTask.allow_resubmit : "N/A")) + ); + + const rawState = Object.keys(submissionData?.state || {})[0] ?? null; + + const validStates = ["Rejected", "WaitingForReview", "Accepted"] as const; + type SubmissionState = typeof validStates[number]; + + const submissionState = validStates.includes(rawState as SubmissionState) + ? (rawState as SubmissionState) + : null; + + const { reasonText, showRejectionReason } = getRejectionInfo( + submissionData ?? null, + submissionState + ) + + const inviteLink = 'DiscordTask' in discordTask.task_content ? discordTask.task_content.DiscordTask.invite_link : undefined; + + return ( +
+
+
+ {submissionState === "WaitingForReview" && ( + + )} + {submissionState === "Accepted" && ( + + )} +
+
+
+
+
+ { 'DiscordTask' in discordTask.task_content && ( + <> +

+ {discordTask.task_content.DiscordTask.task_title} +

+

+ {discordTask.task_content.DiscordTask.task_description} +

+ + )} +
+ {showRejectionReason && ( +
+

Rejected reason:

+

+ {reasonText} +

+
+ )} + {canSubmit && !accessToken && ( + + )} + {canSubmit && accessToken && !isJoined && inviteLink && ( + + )} + {canSubmit && accessToken && isJoined && ( + + )} + {!user && ( +
+ +
+ )} +
+
+ ); +}; + +export default DiscordTask; \ No newline at end of file diff --git a/src/atlas_frontend/src/components/Task/tasks/GenericTask.tsx b/src/atlas_frontend/src/components/Task/tasks/GenericTask.tsx index 012bb0c..6e15e91 100644 --- a/src/atlas_frontend/src/components/Task/tasks/GenericTask.tsx +++ b/src/atlas_frontend/src/components/Task/tasks/GenericTask.tsx @@ -30,8 +30,10 @@ import { FaCaretRight } from "react-icons/fa6"; import { shortPrincipal } from "../../../utils/icp"; import { FiCopy } from "react-icons/fi"; +type GenericTaskType = Extract['GenericTask']; + interface GenericTaskProps { - genericTask: TaskType["GenericTask"]; + genericTask: GenericTaskType; spacePrincipal: Principal; taskId: string; subtaskId: number; @@ -206,10 +208,11 @@ const listForm = useForm({ : null; const canSubmit = user && isUserInHub && ( - currentSubmissionState === null || - (currentSubmissionState === "Rejected" && genericTask.task_content.TitleAndDescription.allow_resubmit) + currentSubmissionState === null || + (currentSubmissionState === "Rejected" && ("TitleAndDescription" in genericTask.task_content + ? genericTask.task_content.TitleAndDescription.allow_resubmit : "N/A")) ); - + const rawState = Object.keys(submissionData?.state || {})[0] ?? null; const validStates = ["Rejected", "WaitingForReview", "Accepted"] as const; type SubmissionState = typeof validStates[number]; @@ -403,7 +406,7 @@ const listForm = useForm({ ['TwitterTask']; + +interface TwitterTaskProps { + twitterTask: TwitterTaskType; + spacePrincipal: Principal; + taskId: string; + subtaskId: number; + unAuthAtlasSpace: ActorSubclass<_SERVICE> | null; + isUserInHub: boolean +} + +interface TwitterTaskFormInput { + taskSubmission: string; +} + +const maxDescriptionLength = 500; + +const schema = yup.object({ + taskSubmission: yup + .string() + .max(maxDescriptionLength) + .trim() + .min(2) + .required() + .label("Task submission"), +}); + +const TwitterTask = ({ + twitterTask, + spacePrincipal, + taskId, + subtaskId, + unAuthAtlasSpace, + isUserInHub +}: TwitterTaskProps) => { + const dispatch = useDispatch(); + const { user } = useAuth(); + const { signIn } = useTwitterAuth(); + const [openSubmission, setSubmission] = useState(false); + const { register, handleSubmit} = useForm({ + resolver: yupResolver(schema), + defaultValues: { + taskSubmission: "", + }, + }); + const { connect } = useAuth(); + const authAtlasSpace = useAuthAtlasSpaceActor(spacePrincipal); + + const handleSubmitResponse: SubmitHandler = async ({ + taskSubmission, + }) => { + if (!authAtlasSpace || !unAuthAtlasSpace) return; + + await runWithLoading(async () => { + const call = submitSubtaskSubmission({ + authAtlasSpace, + taskId: BigInt(taskId), + subtaskId: BigInt(subtaskId), + submission: { Text: { content: taskSubmission } }, + }); + await toast.promise(call, { + loading: "Submitting response...", + success: "Submitted response.", + error: getErrorWithInfoToast("Failed to submit response."), + }); + + setSubmission(false); + getSpaceTasks({ + spaceId: spacePrincipal.toString(), + unAuthAtlasSpace, + dispatch, + }); + }, dispatch, () => setSubmission(false)); + }; + + const [, submissionData] = user?.principal + ? (twitterTask.submission.find( + ([principal]) => principal.toString() === user.principal.toString() + ) ?? []) + : []; + + const currentSubmissionState = submissionData?.state + ? Object.keys(submissionData?.state)[0] + : null; + + const canSubmit = user && isUserInHub && ( + currentSubmissionState === null || + (currentSubmissionState === "Rejected" && ("TwitterTask" in twitterTask.task_content + ? twitterTask.task_content.TwitterTask.allow_resubmit : "N/A")) + ); + + const rawState = Object.keys(submissionData?.state || {})[0] ?? null; + + const validStates = ["Rejected", "WaitingForReview", "Accepted"] as const; + type SubmissionState = typeof validStates[number]; + + const submissionState = validStates.includes(rawState as SubmissionState) + ? (rawState as SubmissionState) + : null; + + const { reasonText, showRejectionReason } = getRejectionInfo( + submissionData ?? null, + submissionState + ) + + return ( +
+
+
+ {submissionState === "WaitingForReview" && ( + + )} + {submissionState === "Accepted" && ( + + )} +
+
+
+
+
+ { 'TwitterTask' in twitterTask.task_content && ( + <> +

+ {twitterTask.task_content.TwitterTask.task_title} +

+

+ {twitterTask.task_content.TwitterTask.task_description} +

+ + )} +
+ {showRejectionReason && ( +
+

Rejected reason:

+

+ {reasonText} +

+
+ )} + {canSubmit && openSubmission && ( +
+
+

Submit response:

+ +
+
+ +
+
+ +
+
+ )} + {canSubmit && !openSubmission && ( +
+ +
+ )} + {!user && ( +
+ +
+ )} +
+
+ ); +}; + +export default TwitterTask; \ No newline at end of file diff --git a/src/atlas_frontend/src/components/Task/tasks/taskRegistry.ts b/src/atlas_frontend/src/components/Task/tasks/taskRegistry.ts new file mode 100644 index 0000000..e888d71 --- /dev/null +++ b/src/atlas_frontend/src/components/Task/tasks/taskRegistry.ts @@ -0,0 +1,9 @@ +import GenericTask from './GenericTask'; +import DiscordTask from './DiscordTask'; +import TwitterTask from './TwitterTask'; + +export const taskRegistry = { + GenericTask, + DiscordTask, + TwitterTask +}; \ No newline at end of file diff --git a/src/atlas_frontend/src/hooks/useDiscordAdmin.ts b/src/atlas_frontend/src/hooks/useDiscordAdmin.ts new file mode 100644 index 0000000..7f81792 --- /dev/null +++ b/src/atlas_frontend/src/hooks/useDiscordAdmin.ts @@ -0,0 +1,96 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { Principal } from "@dfinity/principal"; +import toast from "react-hot-toast"; +import { getDiscordGuilds, validateDiscordInvite } from "../canisters/atlasSpace/api"; +import type { DiscordGuild as DiscordGuildType } from "../components/Integrations/discord/types"; +import { useDiscordAuth } from "./useDiscordAuth"; +import { validateDiscordInviteLink } from "../utils/discord"; + +export type ValidationStatus = "idle" | "validating" | "valid" | "invalid"; + +export interface ValidationState { + status: ValidationStatus; + error?: string | null; + expiresAt?: string | null; +} + +export const useDiscordAdmin = ( + inviteLink?: string, + guildId?: string | null, + isLinkFormatValid?: boolean +) => { + const { accessToken } = useDiscordAuth(); + const [discordGuilds, setDiscordGuilds] = useState([]); + const [loading, setLoading] = useState(false); + const [validationState, setValidationState] = useState({ + status: "idle", + }); + const hasFetchedGuilds = useRef(false); + const previousValidationInput = useRef<{ inviteLink?: string; guildId?: string } | null>(null); + + const fetchAdminGuilds = useCallback(async () => { + if (!accessToken || hasFetchedGuilds.current) return; + hasFetchedGuilds.current = true; + setLoading(true); + try { + const toastId = toast.loading("Loading Discord guilds..."); + const guilds = await getDiscordGuilds(accessToken); + setDiscordGuilds(guilds); + toast.success("Discord guilds loaded!", { id: toastId }); + } catch (err) { + console.error('Failed to fetch Discord guilds: ', err); + toast.error('Failed to load Discord guilds.'); + } finally { + setLoading(false); + } + }, [accessToken]); + + useEffect(() => { + if (accessToken) { + fetchAdminGuilds(); + } + }, [accessToken, fetchAdminGuilds]); + + useEffect(() => { + if (!isLinkFormatValid || !inviteLink || !guildId) { + setValidationState({ status: "idle" }); + return; + } + + if ( + validationState.status === "valid" && + previousValidationInput.current?.inviteLink === inviteLink && + previousValidationInput.current?.guildId === guildId + ) { + return; + } + + previousValidationInput.current = { inviteLink, guildId }; + + const validate = async () => { + setValidationState({ status: "validating" }); + + const result = await validateDiscordInviteLink( + inviteLink, + guildId, + validateDiscordInvite + ); + + setValidationState({ + status: result.status, + expiresAt: result.expiresAt, + error: result.error, + }); + }; + + const timeoutId = setTimeout(validate, 500); + return () => clearTimeout(timeoutId); + }, [inviteLink, guildId, isLinkFormatValid]); + + return { + adminGuilds: discordGuilds, + validationState, + loading, + fetchAdminGuilds, + }; +}; \ No newline at end of file diff --git a/src/atlas_frontend/src/hooks/useDiscordAuth.ts b/src/atlas_frontend/src/hooks/useDiscordAuth.ts new file mode 100644 index 0000000..87029b9 --- /dev/null +++ b/src/atlas_frontend/src/hooks/useDiscordAuth.ts @@ -0,0 +1,63 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { useAuth } from "@nfid/identitykit/react"; +import toast from "react-hot-toast"; +import { setDiscordUserAccessToken } from "../store/slices/userSlice"; +import type { RootState } from "../store/store"; +import { getOAuth2URL, getUserData, type UserData } from "../components/Integrations/discord/discord"; + +export const useDiscordAuth = () => { + const dispatch = useDispatch(); + const { user } = useAuth(); + const accessToken = useSelector((state: RootState) => state.user.accessToken); + const [discordUser, setDiscordUser] = useState(null); + const [loading, setLoading] = useState(false); + + const signIn = () => { + window.open(getOAuth2URL(user?.principal.toString()), "_blank", "width=500,height=600"); + }; + + const fetchUserData = useCallback( + async (token: string) => { + setLoading(true); + try { + const userData: UserData = await getUserData(token); + setDiscordUser(userData); + dispatch(setDiscordUserAccessToken({ accessToken: token })); + toast.success(`Welcome, ${userData.username}!`, { id: "discord-welcome" }); + } catch (err) { + console.error('Failed to fetch Discord data: ', err); + toast.error('Failed to load Discord data.'); + } finally { + setLoading(false); + } + }, + [dispatch] + ); + + const handleMessage = useCallback( + (event: MessageEvent) => { + if (event.origin !== window.location.origin) return; + const { accessToken } = event.data as { accessToken: string }; + if (accessToken) { + fetchUserData(accessToken); + } + }, + [fetchUserData] + ); + + useEffect(() => { + window.addEventListener('message', handleMessage); + return () => { + window.removeEventListener("message", handleMessage); + }; + }, [handleMessage]); + + return { + signIn, + accessToken, + discordUser, + isAuthenticated: !!accessToken, + loading, + }; +}; \ No newline at end of file diff --git a/src/atlas_frontend/src/hooks/useDiscordUser.ts b/src/atlas_frontend/src/hooks/useDiscordUser.ts new file mode 100644 index 0000000..34d049b --- /dev/null +++ b/src/atlas_frontend/src/hooks/useDiscordUser.ts @@ -0,0 +1,56 @@ +import { useState } from "react"; +import type { Principal } from "@dfinity/principal"; +import toast from "react-hot-toast"; +import { useAuthAtlasSpaceActor } from "./identityKit"; +import { getDiscordGuilds } from "../canisters/atlasSpace/api"; +import { useDiscordAuth } from "./useDiscordAuth"; + +export const useDiscordUser = (spacePrincipal?: Principal) => { + const { accessToken, discordUser } = useDiscordAuth(); + const [isJoined, setIsJoined] = useState(false); + const authAtlasSpace = spacePrincipal + ? useAuthAtlasSpaceActor(spacePrincipal) + : null; + + const joinServer = (inviteLink: string) => { + window.open(inviteLink, "_blank"); + setIsJoined(true); + }; + + const checkGuildMembership = async (requiredGuildId: string) => { + if (!accessToken) { + toast.error("Authentication details are missing."); + return false; + } + + const toastId = toast.loading("Fetching your Discord guilds..."); + + try { + const userGuilds = await getDiscordGuilds(accessToken); + toast.loading("Checking if you are a member of the guild...", { id: toastId }); + + const isMember = userGuilds.some((guild) => guild.id === requiredGuildId); + + if (isMember) { + toast.success("Approved! You are a member of the guild.", { id: toastId }); + return true; + } else { + toast.error(`You have to be a member of the required guild.`, { id: toastId }); + setIsJoined(false); + return false; + } + } catch (error) { + console.error("Failed to check guild membership:", error); + toast.error("Failed to verify guild membership. Please try again.", { id: toastId }); + return false; + } + }; + + return { + isJoined, + setIsJoined, + joinServer, + checkGuildMembership, + discordUser, + }; +}; \ No newline at end of file diff --git a/src/atlas_frontend/src/hooks/useTwitterAuth.ts b/src/atlas_frontend/src/hooks/useTwitterAuth.ts new file mode 100644 index 0000000..fa72845 --- /dev/null +++ b/src/atlas_frontend/src/hooks/useTwitterAuth.ts @@ -0,0 +1,87 @@ +import { useCallback, useState } from "react"; +import toast from "react-hot-toast"; +import { openTwitterLoginPopup } from "../components/Integrations/twitter/twitter.ts"; +import { exchange_code_for_token } from "../canisters/atlasSpace/api"; +import { useAuthAtlasSpaceActor } from "./identityKit"; +import { useParams } from "react-router-dom"; +import { Principal } from "@dfinity/principal"; + +type XUser = { + data: { + id: string; + name: string; + username: string; + created_at: string; + }; +} + +export const useTwitterAuth = () => { + const [xUser, setXUser] = useState(null); + const [loading, setLoading] = useState(false); + const { spacePrincipal } = useParams(); + + const parsedSpacePrincipal = spacePrincipal + ? Principal.from(spacePrincipal) + : null; + const authAtlasSpace = parsedSpacePrincipal + ? useAuthAtlasSpaceActor(parsedSpacePrincipal) + : null; + + const signIn = useCallback(async () => { + setLoading(true); + try { + const { code, codeVerifier } = await openTwitterLoginPopup({ + clientId: import.meta.env.PUBLIC_X_CLIENT_ID, + redirectUri: import.meta.env.PUBLIC_X_REDIRECT_URI, + scope: import.meta.env.PUBLIC_X_SCOPE + }); + console.log("Received code from X:", code); + if (parsedSpacePrincipal) { + console.log("authAtlasSpace", authAtlasSpace); + if (!authAtlasSpace) throw new Error("authAtlasSpace is not initialized"); + const rawUserJsonString = await exchange_code_for_token({ + authAtlasSpace: authAtlasSpace, + code, + codeVerifier + }); + if (typeof rawUserJsonString === 'string' && rawUserJsonString.startsWith("HTTP Error")) { + throw new Error("Backend returned an error while retrieving user data."); + } + const userResponse: XUser = JSON.parse(rawUserJsonString as string); + + if (!userResponse) { + throw new Error("No user data in API X response."); + } + + const finalXUser: XUser = { + data: { + id: userResponse.data.id, + name: userResponse.data.name, + username: userResponse.data.username, + created_at: userResponse.data.created_at, + }, + }; + setXUser(finalXUser); + + console.log("Successfully logged in and retrieved user X's data."); + console.log("Full user object:", finalXUser); + console.log("Username:", finalXUser.data.username); + console.log("Created at:", finalXUser.data.created_at); + + toast.success("Logged in to X successfully. Hi " + finalXUser.data.username + "!"); + } + + } catch (err) { + console.error("Twitter auth error:", err); + toast.error("Login to X failed."); + setXUser(null); + setLoading(false); + } + }, [authAtlasSpace]); + + return { + signIn, + xUser, + loading, + }; +}; \ No newline at end of file diff --git a/src/atlas_frontend/src/modals/tasks/DiscordTask.tsx b/src/atlas_frontend/src/modals/tasks/DiscordTask.tsx new file mode 100644 index 0000000..a21f15e --- /dev/null +++ b/src/atlas_frontend/src/modals/tasks/DiscordTask.tsx @@ -0,0 +1,248 @@ +import React, { useEffect, useState } from "react"; +import type { + FieldErrors, + FieldValues, + Path, + UseFormRegister, +} from "react-hook-form"; +import Button from "../../components/Shared/Button"; +import type { DiscordGuild as DiscordGuildType } from "../../components/Integrations/discord/types"; +import { Principal } from "@dfinity/principal"; +import { useDiscordAdmin } from "../../hooks/useDiscordAdmin"; +import { useDiscordAuth } from "../../hooks/useDiscordAuth"; + +interface DiscordGuildProps { + guild: DiscordGuildType; + onClick: () => void; +} + +const DiscordGuild: React.FC = ({ guild, onClick }) => { + const iconUrl = guild.icon?.[0] + ? `https://cdn.discordapp.com/icons/${guild.id}/${guild.icon}.${ + guild.icon?.startsWith('a_') ? 'gif' : 'png' + }?size=64` + : null; + + return ( +
+ {iconUrl && ( + {guild.name} + )} + {guild.name} +
+ ); +}; + +interface DiscordGuildDropdownProps { + guilds: DiscordGuildType[]; + selectedGuild: DiscordGuildType | null; + onSelect: (guild: DiscordGuildType) => void; +} + +const DiscordGuildDropdown: React.FC = ({ + guilds, + selectedGuild, + onSelect, +}) => { + const [isOpen, setIsOpen] = useState(false); + + const handleSelect = (guild: DiscordGuildType) => { + onSelect(guild); + setIsOpen(false); + }; + + const selectedGuildIconUrl = selectedGuild?.icon?.[0] + ? `https://cdn.discordapp.com/icons/${selectedGuild.id}/${ + selectedGuild.icon + }.${ + selectedGuild.icon?.startsWith('a_') ? 'gif' : 'png' + }?size=64` + : null; + + return ( +
+
setIsOpen(!isOpen)} + > + {selectedGuild && selectedGuildIconUrl && ( + {selectedGuild.name} + )} + {selectedGuild ? selectedGuild.name : '-- Select a guild --'} +
+ {isOpen && ( +
+ {guilds.map((guild) => ( + handleSelect(guild)} + /> + ))} +
+ )} +
+ ); +}; + + +interface DiscordTaskProps { + register: UseFormRegister; + errors?: FieldErrors; + index: number; + maxTitleLength?: number; + maxDescriptionLength?: number; + guildId?: string; + inviteLink?: string; + spacePrincipal: Principal; + setInviteValid: (isValid: boolean) => void; +} + +const DiscordTask = ({ + register, + index, + errors, + maxTitleLength, + maxDescriptionLength, + guildId, + spacePrincipal, + setInviteValid, +}: DiscordTaskProps) => { + const [selectedGuild, setSelectedGuild] = useState(null); + const [inviteLink, setInviteLink] = useState(""); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const titleError = (errors?.tasks as any[])?.[index]?.title?.message; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const descriptionError = (errors?.tasks as any[])?.[index]?.description + ?.message; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const guildIdError = (errors?.tasks as any[])?.[index]?.guildId?.message; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const inviteLinkError = (errors?.tasks as any[])?.[index]?.inviteLink?.message; + + const { signIn, accessToken } = useDiscordAuth(); + const { adminGuilds, validationState, fetchAdminGuilds } = useDiscordAdmin( + spacePrincipal, + inviteLink, + selectedGuild?.id, + !inviteLinkError + ); + + useEffect(() => { + setInviteValid(validationState.status === "valid"); + }, [validationState, setInviteValid]); + + useEffect(() => { + if (guildId && adminGuilds.length > 0) { + const initialGuild = adminGuilds.find((g) => g.id === guildId); + if (initialGuild) { + setSelectedGuild(initialGuild); + } + } + }, [guildId, adminGuilds]); + + useEffect(() => { + if (accessToken) { + fetchAdminGuilds(); + } + }, [accessToken, fetchAdminGuilds]); + + const { onChange } = register(`tasks.${index}.guildId` as Path); + const { onChange: onInviteLinkChange, ...inviteLinkProps } = register( + `tasks.${index}.inviteLink` as Path + ); + + return ( +
+

Title:

+ )} + className={`border-2 p-2 rounded-xl ${ + titleError && "border-red-500" + }`} + /> + {titleError && {titleError}} + +

Description:

+ + {descriptionError && ( + {descriptionError} + )} +

Guild ID:

+ { + setSelectedGuild(guild); + onChange({ + target: { name: `tasks.${index}.guildId`, value: guild.id }, + }); + }} + /> + {guildIdError && ( + {guildIdError} + )} + {!accessToken && ( + + )} +

Invitation Link:

+ { + setInviteLink(e.target.value); + onInviteLinkChange(e); + }} + disabled={!selectedGuild} + className={`border-2 p-2 rounded-xl bg-white text-black w-full ${ + !selectedGuild ? "bg-gray-200" : "" + } ${ + validationState.status === "validating" + ? "border-yellow-500" + : validationState.status === "invalid" + ? "border-red-500" + : validationState.status === "valid" + ? "border-green-500" + : "" + } ${inviteLinkError && "border-red-500"}`} + /> +
+ {validationState.status === "validating" && ( + Validating... + )} + {validationState.status === "invalid" && ( + {validationState.error} + )} + {validationState.status === "valid" && ( + + Invite link is valid! + {' Expires at: '} + {validationState.expiresAt + ? new Date(validationState.expiresAt).toLocaleString() + : 'Never'} + + )} + {validationState.status === "idle" && inviteLinkError && ( + {inviteLinkError} + )} +
+
+ ); +}; +export default DiscordTask; \ No newline at end of file diff --git a/src/atlas_frontend/src/modals/tasks/TwitterTask.tsx b/src/atlas_frontend/src/modals/tasks/TwitterTask.tsx new file mode 100644 index 0000000..ef83289 --- /dev/null +++ b/src/atlas_frontend/src/modals/tasks/TwitterTask.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import type { + FieldErrors, + FieldValues, + Path, + UseFormRegister, +} from "react-hook-form"; + +interface TwitterTaskProps { + register: UseFormRegister; + errors?: FieldErrors; + index: number; + maxTitleLength?: number; + maxDescriptionLength?: number; +} + +const TwitterTask = ({ + register, + index, + errors, + maxTitleLength, + maxDescriptionLength, +}: TwitterTaskProps) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const titleError = (errors?.tasks as any[])?.[index]?.title?.message; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const descriptionError = (errors?.tasks as any[])?.[index]?.description + ?.message; + + return ( +
+

Title:

+ )} + className={`border-2 p-2 rounded-xl ${ + descriptionError && "border-red-500" + }`} + /> + {titleError && {titleError}} + +

Description:

+ + {descriptionError && ( + {descriptionError} + )} +
+ ); +}; + +export default TwitterTask; \ No newline at end of file diff --git a/src/atlas_frontend/src/router/index.tsx b/src/atlas_frontend/src/router/index.tsx index 811f1f1..454b9ce 100644 --- a/src/atlas_frontend/src/router/index.tsx +++ b/src/atlas_frontend/src/router/index.tsx @@ -9,6 +9,7 @@ import Task from "../components/Task/index.tsx"; import { ADMIN_PATH, DISCORD_CALLBACK_PATH, + TWITTER_CALLBACK_PATH, HELP_PATH, SPACE_BUILDER_PATH, SPACE_EDIT_PATH, @@ -23,6 +24,7 @@ import Wallet from "../components/Wallet/index.tsx"; import Help from "../components/Help/index.tsx"; import CreateNewTaskModal from "../modals/CreateNewTaskModal.tsx"; import Admin from "../components/Admin/index.tsx"; +import TwitterCallback from "../components/Integrations/TwitterCallback.tsx"; const Router = () => { return ( @@ -33,6 +35,7 @@ const Router = () => { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/atlas_frontend/src/router/paths.ts b/src/atlas_frontend/src/router/paths.ts index c80c933..c08ad82 100644 --- a/src/atlas_frontend/src/router/paths.ts +++ b/src/atlas_frontend/src/router/paths.ts @@ -9,6 +9,7 @@ export const EDIT_TASK_PATH = TASK_PATH + "/edit"; export const SPACE_BUILDER_PATH = SPACES_PATH + "/builder"; export const SPACE_EDIT_PATH = SPACE_BUILDER_PATH + "/:spacePrincipal"; export const DISCORD_CALLBACK_PATH = "/auth/discord/callback"; +export const TWITTER_CALLBACK_PATH = "/auth/x/callback"; export const WALLET_PATH = "/wallet"; export const HELP_PATH = "/help"; export const ADMIN_PATH = "/admin"; diff --git a/src/atlas_frontend/src/store/slices/userSlice.ts b/src/atlas_frontend/src/store/slices/userSlice.ts index 6e240d8..878c107 100644 --- a/src/atlas_frontend/src/store/slices/userSlice.ts +++ b/src/atlas_frontend/src/store/slices/userSlice.ts @@ -78,6 +78,7 @@ interface UserState { }; blockchain: StorableUser | null; userHub: string | null; + accessToken: string | null; } const initialState = (): UserState => { @@ -88,6 +89,7 @@ const initialState = (): UserState => { }, userHub: null, blockchain: null, + accessToken: null, }; }; @@ -107,6 +109,11 @@ export const userSlice = createSlice({ ...action.payload, }; }, + setDiscordUserAccessToken: (state, action: PayloadAction<{ + accessToken: string; + }>) => { + state.accessToken = action.payload.accessToken; + }, }, selectors: { selectUserBlockchainData: (userState: UserState) => { @@ -122,11 +129,16 @@ export const userSlice = createSlice({ selectUserTxs: (userState: UserState) => { return userState.txs; }, + selectDiscordUserAccessToken: (userState: UserState) => { + if(userState.accessToken) + return userState.accessToken; + return null; + }, }, }); -export const { setUserBlockchainData, setCkUsdcBalance, appendUserTxs } = +export const { setUserBlockchainData, setCkUsdcBalance, appendUserTxs, setDiscordUserAccessToken } = userSlice.actions; -export const { selectUserBlockchainData, selectUserCkUsdc, selectUserTxs } = +export const { selectUserBlockchainData, selectUserCkUsdc, selectUserTxs, selectDiscordUserAccessToken } = userSlice.selectors; export default userSlice.reducer; diff --git a/src/atlas_frontend/src/utils/discord.ts b/src/atlas_frontend/src/utils/discord.ts new file mode 100644 index 0000000..604bf9e --- /dev/null +++ b/src/atlas_frontend/src/utils/discord.ts @@ -0,0 +1,59 @@ +import type { DiscordInviteApiResponse } from "../components/Integrations/discord/types"; + +export interface DiscordValidationResult { + status: "valid" | "invalid"; + expiresAt?: string | null; + error?: string | null; +} + +export const createTimeoutPromise = (timeoutMs: number = 5000): Promise => { + return new Promise((_, reject) => + setTimeout( + () => reject(new Error("Validation timed out.")), + timeoutMs + ) + ); +}; + +export const validateDiscordInviteLink = async ( + inviteLink: string, + guildId: string, + validateDiscordInviteFn: (inviteCode: string, guildId: string) => Promise +): Promise => { + try { + const inviteCodeMatch = inviteLink.match( + /(?:https?:\/\/)?(?:discord\.(?:gg|com\/invite)\/)?([a-zA-Z0-9-]+)/ + ); + const inviteCode = inviteCodeMatch ? inviteCodeMatch[1] : null; + + if (!inviteCode) { + throw new Error("Invalid invite link."); + } + + const validationPromise = validateDiscordInviteFn(inviteCode, guildId); + const timeoutPromise = createTimeoutPromise(); + + const result = await Promise.race([validationPromise, timeoutPromise]); + + if (result && result.expires_at && result.expires_at.length > 0) { + return { + status: "valid", + expiresAt: result.expires_at, + }; + } else { + return { status: "valid" }; + } + } catch (err) { + console.error("Validation error:", err); + let message = "An unknown error occurred during validation."; + if (err instanceof Error) { + message = err.message || "Invalid invite link or guild ID."; + } else if (typeof err === "string") { + message = err; + } + return { + status: "invalid", + error: message, + }; + } +}; \ No newline at end of file diff --git a/src/atlas_frontend/src/utils/taskMapper.ts b/src/atlas_frontend/src/utils/taskMapper.ts new file mode 100644 index 0000000..c3756d9 --- /dev/null +++ b/src/atlas_frontend/src/utils/taskMapper.ts @@ -0,0 +1,72 @@ +import type { AnswerFormat } from "../../../declarations/atlas_space/atlas_space.did"; + +export enum TaskType { + Generic = "generic", + Discord = "discord", + Twitter = "twitter", +} + +type TaskInput = { + taskType: TaskType; + title: string; + description: string; + guildId?: string; + inviteLink?: string; + allowResubmit: boolean; + answerFormat?: AnswerFormat; +}; + +interface BaseTaskContent { + title: string, + description: string, + allow_resubmit: boolean, +} + +export interface GenericTaskContent extends BaseTaskContent { + task_type: "generic"; + answer_format: AnswerFormat; +}; + +export interface DiscordTaskContent extends BaseTaskContent { + task_type: "discord"; + invite_link: string; + guild_id: string; +}; + +export interface TwitterTaskContent extends BaseTaskContent { + task_type: "twitter"; +}; + +type TaskContent = GenericTaskContent | DiscordTaskContent | TwitterTaskContent; + +type MapperFn = (task: TaskInput) => TaskContent; + +const taskMappers: Record = { + [TaskType.Generic]: (task) => ({ + task_type: "generic", + title: task.title, + description: task.description, + allow_resubmit: task.allowResubmit, + answer_format: task.answerFormat!, + }), + + [TaskType.Discord]: (task) => ({ + task_type: "discord", + title: task.title, + description: task.description, + invite_link: task.inviteLink!, + guild_id: task.guildId!, + allow_resubmit: task.allowResubmit, + }), + + [TaskType.Twitter]: (task) => ({ + task_type: "twitter", + title: task.title, + description: task.description, + allow_resubmit: task.allowResubmit, + }), +}; + +export const mapTasks = (tasks: TaskInput[]): TaskContent[] => { + return tasks.map((task) => taskMappers[task.taskType](task)); +}; \ No newline at end of file diff --git a/src/atlas_frontend/src/utils/tasks.ts b/src/atlas_frontend/src/utils/tasks.ts index 23b9709..3c7fbfb 100644 --- a/src/atlas_frontend/src/utils/tasks.ts +++ b/src/atlas_frontend/src/utils/tasks.ts @@ -1,6 +1,63 @@ +import type { Principal } from "@dfinity/principal"; +import type { SubmissionData, Task, TaskType, TokenReward } from "../../../declarations/atlas_space/atlas_space.did"; import type { AnyTask } from "../canisters/atlasSpace/api"; import { formatDuration } from "./date"; +type Submission = [Principal, SubmissionData]; + +type SubmissionExtractor = ( + task: Extract> +) => Submission[]; + +const submissionExtractors: { + [K in keyof TaskType]?: SubmissionExtractor; +} = { + GenericTask: (t: Extract) => t.GenericTask.submission ?? [], + DiscordTask: (t: Extract) => t.DiscordTask.submission ?? [], +}; + +export class BlockchainTask implements Task { + public tasks: TaskType[]; + public creator: Principal; + public task_title: string; + public token_reward: TokenReward; + public rewarded: Principal[]; + public number_of_uses: bigint; + public start_time: bigint; + public end_time: bigint; + public timer_id: [] | [bigint]; + + constructor(public task: Task) { + this.tasks = task.tasks; + this.creator = task.creator; + this.task_title = task.task_title; + this.token_reward = task.token_reward; + this.rewarded = task.rewarded; + this.number_of_uses = task.number_of_uses; + this.start_time = task.start_time; + this.end_time = task.end_time; + this.timer_id = task.timer_id; + } + + getSubmissions(): Submission[] { + const last = this.tasks.at(-1); + if (!last) return []; + + for (const [key, extractor] of Object.entries(submissionExtractors)) { + if (key in last) { + return (extractor as (task: TaskType) => Submission[])(last); + } + } + return []; + } + + getAcceptedSubmissions(): number { + return this.getSubmissions().filter( + ([, submission]) => "Accepted" in submission.state + ).length; + } +} + export const getTaskType = ( task: AnyTask, time: number @@ -22,4 +79,4 @@ export const getStartingIn = ( return getTaskType === "starting" ? formatDuration(Number(task.start_time) - Number(time)) : undefined; -}; +}; \ No newline at end of file diff --git a/src/atlas_space/Cargo.toml b/src/atlas_space/Cargo.toml index b3f85e1..ffbe45e 100644 --- a/src/atlas_space/Cargo.toml +++ b/src/atlas_space/Cargo.toml @@ -19,7 +19,10 @@ shared = { workspace = true } ic-ledger-types = { workspace = true } ethnum = { workspace = true } num-bigint = { workspace = true } -icrc-ledger-types = "0.1.8" sha2 = { workspace = true } +ic-management-canister-types = { workspace = true } +serde_json = { workspace = true} +icrc-ledger-types = "0.1.8" ic-cdk-timers = "0.12.2" -slotmap = "1.0.7" \ No newline at end of file +slotmap = "1.0.7" +base64 = "0.22.1" \ No newline at end of file diff --git a/src/atlas_space/atlas_space.did b/src/atlas_space/atlas_space.did index a36d106..de5ad31 100644 --- a/src/atlas_space/atlas_space.did +++ b/src/atlas_space/atlas_space.did @@ -88,8 +88,9 @@ type GetTasksRes = record { type Result = variant { Ok; Err : Error }; type Result_1 = variant { Ok; Err : text }; type Result_2 = variant { Ok : nat64; Err : Error }; -type Result_3 = variant { Ok : GetClosedTasksRes; Err : Error }; -type Result_4 = variant { Ok : GetTasksRes; Err : Error }; +type Result_3 = variant { Ok : text; Err : text }; +type Result_4 = variant { Ok : GetClosedTasksRes; Err : Error }; +type Result_5 = variant { Ok : GetTasksRes; Err : Error }; type SpaceArgs = variant { UpgradeArg : record { version : nat64 }; InitArg : SpaceInitArg; @@ -119,6 +120,7 @@ type Submission = variant { Empty; List : record { items : vec text }; Text : record { content : text }; + Discord : record { username : text; user_id : nat64 }; }; type SubmissionData = record { state : SubmissionState; @@ -138,6 +140,18 @@ type Task = record { number_of_uses : nat64; }; type TaskContent = variant { + TwitterTask : record { + task_description : text; + task_title : text; + allow_resubmit : bool; + }; + DiscordTask : record { + task_description : text; + task_title : text; + invite_link : text; + guild_id : text; + allow_resubmit : bool; + }; TitleAndDescription : record { answer_format : AnswerFormat; task_description : text; @@ -146,6 +160,14 @@ type TaskContent = variant { }; }; type TaskType = variant { + TwitterTask : record { + task_content : TaskContent; + submission : vec record { principal; SubmissionData }; + }; + DiscordTask : record { + task_content : TaskContent; + submission : vec record { principal; SubmissionData }; + }; GenericTask : record { task_content : TaskContent; submission : vec record { principal; SubmissionData }; @@ -161,12 +183,14 @@ service : (SpaceArgs) -> { delete_closed_task : (nat64) -> (Result); edit_space : (EditSpaceArgs) -> (Result); edit_task : (EditTaskArgs) -> (Result); + exchange_code_for_token : (text, text) -> (Result_3); + fetch_x_user_info : (text) -> (Result_3); force_expire_task : (nat64) -> (Result); - get_closed_tasks : (GetTasksArgs) -> (Result_3) query; + get_closed_tasks : (GetTasksArgs) -> (Result_4) query; get_config : () -> (Config) query; get_current_bytecode_version : () -> (nat64) query; - get_expired_tasks : (GetTasksArgs) -> (Result_4) query; - get_open_tasks : (GetTasksArgs) -> (Result_4) query; + get_expired_tasks : (GetTasksArgs) -> (Result_5) query; + get_open_tasks : (GetTasksArgs) -> (Result_5) query; get_space_info : () -> (SpaceInfo) query; get_state : () -> (State) query; reject_subtask_submission : (principal, nat64, nat64, opt text) -> (Result); diff --git a/src/atlas_space/src/methods/update.rs b/src/atlas_space/src/methods/update.rs index bde71c6..c6a2c66 100644 --- a/src/atlas_space/src/methods/update.rs +++ b/src/atlas_space/src/methods/update.rs @@ -1,403 +1,510 @@ -use crate::tasks::task::validate_task_time_edit; -use crate::tasks::task::EditTaskArgs; -use crate::tasks::task::Task; -use crate::tasks::task_types::TaskType; -use crate::tasks::timer_logic; -use crate::update_helpers::{accept_expired_subtask_submission, accept_open_subtask_submission}; -use crate::CreateTaskArgs; -use crate::Submission; -use crate::TaskId; -use crate::{ - errors::Error, - guard::{parent_guard, parent_or_owner_or_admin_guard, user_is_in_space}, - memory, - state::EditSpaceArgs, - tasks::closed_task::ClosedTask, -}; -use candid::Principal; -use ic_cdk::update; -use ic_cdk_timers::TimerId; -use ic_stable_structures::Storable; -use sha2::Digest; -use std::collections::BTreeMap; - -#[update] -pub async fn set_space_name(name: String) -> Result<(), Error> { - parent_or_owner_or_admin_guard().await?; - - memory::mut_state(|state| state.set_space_name(name)); - Ok(()) -} - -#[update] -pub async fn set_space_description(description: String) -> Result<(), Error> { - parent_or_owner_or_admin_guard().await?; - - memory::mut_state(|state| state.set_space_description(description)); - Ok(()) -} - -#[update] -pub async fn set_space_logo(logo: String) -> Result<(), Error> { - parent_or_owner_or_admin_guard().await?; - - memory::mut_state(|state| state.set_space_logo(logo)); - Ok(()) -} - -#[update] -pub async fn set_space_background(background: String) -> Result<(), Error> { - parent_or_owner_or_admin_guard().await?; - - memory::mut_state(|state| state.set_space_background(background)); - Ok(()) -} - -#[update] -pub async fn edit_space(edit_space_args: EditSpaceArgs) -> Result<(), Error> { - parent_or_owner_or_admin_guard().await?; - - memory::mut_state(|state| state.edit_space(edit_space_args)); - Ok(()) -} - -#[update] -pub async fn create_task(args: CreateTaskArgs) -> Result { - let caller = parent_or_owner_or_admin_guard().await?; - args.validate()?; - let next_task_id = memory::mut_state(|state| TaskId::new(state.get_next_task_id())); - let subaccount = sha2::Sha256::digest(next_task_id.u64().to_bytes()).into(); - - let timer_id = timer_logic::schedule_expire_task_timer(next_task_id, args.end_time); - - memory::insert_open_task( - next_task_id, - Task::new(caller, args, subaccount, timer_id.into()) - .await - .unwrap(), - ) - .unwrap(); - - Ok(next_task_id) -} - -#[update] -pub async fn submit_subtask_submission( - task_id: TaskId, - subtask_id: usize, - submission: Submission, -) -> Result<(), Error> { - let caller = user_is_in_space().await?; - - let expired = timer_logic::expire_task_if_expired(task_id).await?; - if expired { - return Err(Error::TaskExpired); - } - - memory::mut_open_task(task_id, |maybe_task| { - let task = maybe_task.as_mut().ok_or(Error::TaskDoNotExists(task_id))?; - task.submit_subtask_submission(caller, subtask_id, submission)?; - - Ok(()) - })??; - - Ok(()) -} - -#[update] -pub async fn accept_subtask_submission( - user: Principal, - task_id: TaskId, - subtask_id: usize, -) -> Result<(), Error> { - parent_or_owner_or_admin_guard().await?; - if memory::get_open_task(&task_id).is_some() { - accept_open_subtask_submission(user, task_id, subtask_id).await - } else { - accept_expired_subtask_submission(user, task_id, subtask_id).await - } -} - -#[update] -pub async fn reject_subtask_submission( - user: Principal, - task_id: TaskId, - subtask_id: usize, - reason: Option, -) -> Result<(), Error> { - parent_or_owner_or_admin_guard().await?; - - let (task, is_open) = memory::get_open_task(&task_id) - .map(|t| (t, true)) - .or_else(|| memory::get_expired_task(&task_id).map(|t| (t, false))) - .ok_or(Error::TaskDoNotExists(task_id))?; - - if task.is_fully_rewarded() { - return Err(Error::AllRewardsClaimed); - } - - if is_open { - memory::mut_open_task(task_id, |maybe_task| { - let task = maybe_task.as_mut().ok_or(Error::TaskDoNotExists(task_id))?; - task.reject_subtask_submission(user, subtask_id, reason)?; - Ok(()) - })??; - } else { - memory::mut_expired_task(task_id, |maybe_task| { - let task = maybe_task.as_mut().ok_or(Error::TaskDoNotExists(task_id))?; - task.reject_subtask_submission(user, subtask_id, reason)?; - Ok(()) - })??; - } - - Ok(()) -} - -#[update] -pub async fn withdraw_reward(task_id: TaskId) -> Result<(), Error> { - let caller = user_is_in_space().await?; - let subaccount = sha2::Sha256::digest(task_id.u64().to_bytes()).into(); - - if let Some(mut task) = memory::get_open_task(&task_id) { - task.claim_reward(caller, subaccount).await?; - - memory::mut_open_task(task_id, |maybe_task| { - let task_mut = maybe_task.as_mut().ok_or(Error::TaskDoNotExists(task_id))?; - *task_mut = task; - Ok(()) - })??; - - return Ok(()); - } - - if let Some(mut task) = memory::get_expired_task(&task_id) { - task.claim_reward(caller, subaccount).await?; - - memory::mut_expired_task(task_id, |maybe_task| { - let task_mut = maybe_task.as_mut().ok_or(Error::TaskDoNotExists(task_id))?; - *task_mut = task; - Ok(()) - })??; - - return Ok(()); - } - - if let Some(mut task) = memory::get_closed_task(&task_id) { - task.claim_reward(caller, subaccount).await?; - - memory::mut_closed_task(task_id, |maybe_task| { - let task_mut = maybe_task.as_mut().ok_or(Error::TaskDoNotExists(task_id))?; - *task_mut = task; - Ok(()) - })??; - - return Ok(()); - } - - Err(Error::TaskDoNotExists(task_id)) -} - -#[update] -pub async fn edit_task(args: EditTaskArgs) -> Result<(), Error> { - parent_or_owner_or_admin_guard().await?; - args.validate()?; - - let mut task = - memory::get_open_task(&args.task_id).ok_or(Error::TaskDoNotExists(args.task_id))?; - - let new_start_time = args.start_time.unwrap_or(task.start_time); - let new_end_time = args.end_time.unwrap_or(task.end_time); - validate_task_time_edit(new_start_time, new_end_time)?; - - let mut maybe_new_tasks: Option> = None; - if let Some(new_contents) = &args.task_content { - let old_tasks_with_subs: BTreeMap = task - .tasks - .iter() - .enumerate() - .filter(|(_, task)| !task.get_submission_map().is_empty()) - .collect(); - - if new_contents.len() < old_tasks_with_subs.len() { - return Err(Error::InvalidTaskContent( - "Cannot remove subtask with existing submissions".into(), - )); - } - - let mut new_tasks: Vec = Vec::new(); - for (i, content) in new_contents.iter().enumerate() { - if let Some(old_task) = old_tasks_with_subs.get(&i) { - let some_content = content.as_ref().ok_or({ - Error::InvalidTaskContent( - "Cannot remove subtask with existing submissions".into(), - ) - })?; - if old_task.get_content() != some_content { - return Err(Error::InvalidTaskContent( - "Cannot remove or modify subtask with existing submissions".into(), - )); - } - new_tasks.push((*old_task).clone()); - continue; - } - if content.is_none() { - continue; - } - new_tasks.push(TaskType::from(content.as_ref().unwrap())); - } - - maybe_new_tasks = Some(new_tasks); - } - - let mut final_number_of_uses = task.number_of_uses; - let mut final_token_reward = task.token_reward.clone(); - - let already_rewarded = task.rewarded.len() as u64; - if let Some(new_reward) = &args.token_reward { - let any_submissions = task - .tasks - .iter() - .any(|t| !t.get_submission_map().is_empty()); - if any_submissions { - return Err(Error::InvalidTaskContent( - "Cannot change token_reward because some subtasks already have submissions".into(), - )); - } - final_token_reward = new_reward.clone(); - } - - if let Some(new_uses) = args.number_of_uses { - if new_uses < already_rewarded { - return Err(Error::InvalidTaskContent(format!( - "Cannot set number_of_uses to {new_uses} because {already_rewarded} users already rewarded", - ))); - } - final_number_of_uses = new_uses; - } - - if final_token_reward != task.token_reward || final_number_of_uses != task.number_of_uses { - let subaccount = sha2::Sha256::digest(args.task_id.u64().to_bytes()).into(); - final_token_reward - .adjust_reward( - task.creator, - subaccount, - task.token_reward.clone(), - task.number_of_uses, - final_number_of_uses, - already_rewarded, - ) - .await?; - } - - if let Some(new_end_time) = args.end_time { - if new_end_time != task.end_time { - if let Some(timer_id) = task.timer_id.take() { - ic_cdk_timers::clear_timer(TimerId::try_from(timer_id)?); - } - - let new_timer = timer_logic::schedule_expire_task_timer(args.task_id, new_end_time); - task.timer_id = Some(new_timer.into()); - } - } - - let prev_task_count = task.tasks.len(); - task.edit_task(&args, maybe_new_tasks.as_ref()); - if prev_task_count > task.tasks.len() { - if let Err(e) = task.claim_all_rewards(args.task_id).await { - ic_cdk::println!( - "Failed to claim rewards for task {:?}: {:?}", - args.task_id, - e - ); - } - } - - memory::mut_open_task(args.task_id, |maybe_task| { - let task_in_memory = maybe_task - .as_mut() - .ok_or(Error::TaskDoNotExists(args.task_id))?; - *task_in_memory = task.clone(); - Ok(()) - })??; - - let number_of_uses: usize = task - .number_of_uses - .try_into() - .expect("u64 do not fit in usize?"); - - if number_of_uses == task.rewarded.len() { - ic_cdk::println!("All rewards distributed. Closing task {}", args.task_id); - timer_logic::force_expire_task(args.task_id).await?; - } - - Ok(()) -} - -#[update] -pub async fn force_expire_task(task_id: TaskId) -> Result<(), Error> { - parent_or_owner_or_admin_guard().await?; - timer_logic::force_expire_task(task_id).await?; - Ok(()) -} - -#[update] -pub async fn close_task(task_id: TaskId) -> Result<(), Error> { - parent_or_owner_or_admin_guard().await?; - timer_logic::force_close_task(task_id).await?; - Ok(()) -} - -#[update] -pub async fn delete_closed_task(task_id: TaskId) -> Result<(), Error> { - parent_or_owner_or_admin_guard().await?; - let mut closed_task = - memory::get_closed_task(&task_id).ok_or(Error::TaskDoNotExists(task_id))?; - closed_task.claim_all_rewards(task_id).await?; - memory::delete_closed_task(&task_id).expect("Failed to remove closed task"); - Ok(()) -} - -#[update] -pub fn transfer_space(to: Principal) { - parent_guard().unwrap(); - memory::mut_config(|config| config.owner = to); -} - -#[update] -pub async fn clean_up_space_before_deletion() -> Result<(), String> { - parent_guard().map_err(|e| e.to_string())?; - - let open_tasks: Vec<(TaskId, Task)> = memory::get_all_open_tasks(); - for (task_id, _) in &open_tasks { - timer_logic::force_expire_task(*task_id) - .await - .map_err(|e| format!("Failed to expire task {task_id:?}: {e:?}"))?; - } - - let expired_tasks: Vec<(TaskId, Task)> = memory::get_all_expired_tasks(); - for (task_id, _) in &expired_tasks { - timer_logic::force_close_task(*task_id) - .await - .map_err(|e| format!("Failed to close task {task_id:?}: {e:?}"))?; - } - - let mut errors = Vec::new(); - let closed_tasks: Vec<(TaskId, ClosedTask)> = memory::get_all_closed_tasks(); - - for (task_id, mut closed_task) in closed_tasks { - if let Err(err) = closed_task.claim_all_rewards(task_id).await { - ic_cdk::println!("Failed to claim rewards for task {:?}: {:?}", task_id, err); - errors.push(format!("Task {task_id:?}: {err:?}")); - } - } - - if !errors.is_empty() { - return Err(format!( - "Failed to claim rewards for {} tasks: {:?}", - errors.len(), - errors - )); - } - - Ok(()) -} +use crate::tasks::task::validate_task_time_edit; +use crate::tasks::task::EditTaskArgs; +use crate::tasks::task::Task; +use crate::tasks::task_types::TaskType; +use crate::tasks::task_types::TwitterTokenResponse; +use crate::tasks::timer_logic; +use crate::update_helpers::{accept_expired_subtask_submission, accept_open_subtask_submission}; +use crate::CreateTaskArgs; +use crate::Submission; +use crate::TaskId; +use crate::{ + errors::Error, + guard::{parent_guard, parent_or_owner_or_admin_guard, user_is_in_space}, + memory, + state::EditSpaceArgs, + tasks::closed_task::ClosedTask, +}; +use base64::Engine; +use candid::Principal; +use ic_cdk::management_canister::http_request; +use ic_cdk::management_canister::{HttpHeader, HttpMethod, HttpRequestArgs}; +use ic_cdk::update; +use ic_cdk_timers::TimerId; +use ic_stable_structures::Storable; +use sha2::Digest; +use std::collections::BTreeMap; +use std::env; + +#[update] +pub async fn set_space_name(name: String) -> Result<(), Error> { + parent_or_owner_or_admin_guard().await?; + + memory::mut_state(|state| state.set_space_name(name)); + Ok(()) +} + +#[update] +pub async fn set_space_description(description: String) -> Result<(), Error> { + parent_or_owner_or_admin_guard().await?; + + memory::mut_state(|state| state.set_space_description(description)); + Ok(()) +} + +#[update] +pub async fn set_space_logo(logo: String) -> Result<(), Error> { + parent_or_owner_or_admin_guard().await?; + + memory::mut_state(|state| state.set_space_logo(logo)); + Ok(()) +} + +#[update] +pub async fn set_space_background(background: String) -> Result<(), Error> { + parent_or_owner_or_admin_guard().await?; + + memory::mut_state(|state| state.set_space_background(background)); + Ok(()) +} + +#[update] +pub async fn edit_space(edit_space_args: EditSpaceArgs) -> Result<(), Error> { + parent_or_owner_or_admin_guard().await?; + + memory::mut_state(|state| state.edit_space(edit_space_args)); + Ok(()) +} + +#[update] +pub async fn create_task(args: CreateTaskArgs) -> Result { + let caller = parent_or_owner_or_admin_guard().await?; + args.validate()?; + let next_task_id = memory::mut_state(|state| TaskId::new(state.get_next_task_id())); + let subaccount = sha2::Sha256::digest(next_task_id.u64().to_bytes()).into(); + + let timer_id = timer_logic::schedule_expire_task_timer(next_task_id, args.end_time); + + memory::insert_open_task( + next_task_id, + Task::new(caller, args, subaccount, timer_id.into()) + .await + .unwrap(), + ) + .unwrap(); + + Ok(next_task_id) +} + +#[update] +pub async fn submit_subtask_submission( + task_id: TaskId, + subtask_id: usize, + submission: Submission, +) -> Result<(), Error> { + let caller = user_is_in_space().await?; + + let expired = timer_logic::expire_task_if_expired(task_id).await?; + if expired { + return Err(Error::TaskExpired); + } + + memory::mut_open_task(task_id, |maybe_task| { + let task = maybe_task.as_mut().ok_or(Error::TaskDoNotExists(task_id))?; + task.submit_subtask_submission(caller, subtask_id, submission)?; + + Ok(()) + })??; + + Ok(()) +} + +#[update] +pub async fn accept_subtask_submission( + user: Principal, + task_id: TaskId, + subtask_id: usize, +) -> Result<(), Error> { + parent_or_owner_or_admin_guard().await?; + if memory::get_open_task(&task_id).is_some() { + accept_open_subtask_submission(user, task_id, subtask_id).await + } else { + accept_expired_subtask_submission(user, task_id, subtask_id).await + } +} + +#[update] +pub async fn reject_subtask_submission( + user: Principal, + task_id: TaskId, + subtask_id: usize, + reason: Option, +) -> Result<(), Error> { + parent_or_owner_or_admin_guard().await?; + + let (task, is_open) = memory::get_open_task(&task_id) + .map(|t| (t, true)) + .or_else(|| memory::get_expired_task(&task_id).map(|t| (t, false))) + .ok_or(Error::TaskDoNotExists(task_id))?; + + if task.is_fully_rewarded() { + return Err(Error::AllRewardsClaimed); + } + + if is_open { + memory::mut_open_task(task_id, |maybe_task| { + let task = maybe_task.as_mut().ok_or(Error::TaskDoNotExists(task_id))?; + task.reject_subtask_submission(user, subtask_id, reason)?; + Ok(()) + })??; + } else { + memory::mut_expired_task(task_id, |maybe_task| { + let task = maybe_task.as_mut().ok_or(Error::TaskDoNotExists(task_id))?; + task.reject_subtask_submission(user, subtask_id, reason)?; + Ok(()) + })??; + } + + Ok(()) +} + +#[update] +pub async fn withdraw_reward(task_id: TaskId) -> Result<(), Error> { + let caller = user_is_in_space().await?; + let subaccount = sha2::Sha256::digest(task_id.u64().to_bytes()).into(); + + if let Some(mut task) = memory::get_open_task(&task_id) { + task.claim_reward(caller, subaccount).await?; + + memory::mut_open_task(task_id, |maybe_task| { + let task_mut = maybe_task.as_mut().ok_or(Error::TaskDoNotExists(task_id))?; + *task_mut = task; + Ok(()) + })??; + + return Ok(()); + } + + if let Some(mut task) = memory::get_expired_task(&task_id) { + task.claim_reward(caller, subaccount).await?; + + memory::mut_expired_task(task_id, |maybe_task| { + let task_mut = maybe_task.as_mut().ok_or(Error::TaskDoNotExists(task_id))?; + *task_mut = task; + Ok(()) + })??; + + return Ok(()); + } + + if let Some(mut task) = memory::get_closed_task(&task_id) { + task.claim_reward(caller, subaccount).await?; + + memory::mut_closed_task(task_id, |maybe_task| { + let task_mut = maybe_task.as_mut().ok_or(Error::TaskDoNotExists(task_id))?; + *task_mut = task; + Ok(()) + })??; + + return Ok(()); + } + + Err(Error::TaskDoNotExists(task_id)) +} + +#[update] +pub async fn edit_task(args: EditTaskArgs) -> Result<(), Error> { + parent_or_owner_or_admin_guard().await?; + args.validate()?; + + let mut task = + memory::get_open_task(&args.task_id).ok_or(Error::TaskDoNotExists(args.task_id))?; + + let new_start_time = args.start_time.unwrap_or(task.start_time); + let new_end_time = args.end_time.unwrap_or(task.end_time); + validate_task_time_edit(new_start_time, new_end_time)?; + + let mut maybe_new_tasks: Option> = None; + if let Some(new_contents) = &args.task_content { + let old_tasks_with_subs: BTreeMap = task + .tasks + .iter() + .enumerate() + .filter(|(_, task)| !task.get_submission_map().is_empty()) + .collect(); + + if new_contents.len() < old_tasks_with_subs.len() { + return Err(Error::InvalidTaskContent( + "Cannot remove subtask with existing submissions".into(), + )); + } + + let mut new_tasks: Vec = Vec::new(); + for (i, content) in new_contents.iter().enumerate() { + if let Some(old_task) = old_tasks_with_subs.get(&i) { + let some_content = content.as_ref().ok_or({ + Error::InvalidTaskContent( + "Cannot remove subtask with existing submissions".into(), + ) + })?; + if old_task.get_content() != some_content { + return Err(Error::InvalidTaskContent( + "Cannot remove or modify subtask with existing submissions".into(), + )); + } + new_tasks.push((*old_task).clone()); + continue; + } + if content.is_none() { + continue; + } + new_tasks.push(TaskType::from(content.as_ref().unwrap())); + } + + maybe_new_tasks = Some(new_tasks); + } + + let mut final_number_of_uses = task.number_of_uses; + let mut final_token_reward = task.token_reward.clone(); + + let already_rewarded = task.rewarded.len() as u64; + if let Some(new_reward) = &args.token_reward { + let any_submissions = task + .tasks + .iter() + .any(|t| !t.get_submission_map().is_empty()); + if any_submissions { + return Err(Error::InvalidTaskContent( + "Cannot change token_reward because some subtasks already have submissions".into(), + )); + } + final_token_reward = new_reward.clone(); + } + + if let Some(new_uses) = args.number_of_uses { + if new_uses < already_rewarded { + return Err(Error::InvalidTaskContent(format!( + "Cannot set number_of_uses to {new_uses} because {already_rewarded} users already rewarded", + ))); + } + final_number_of_uses = new_uses; + } + + if final_token_reward != task.token_reward || final_number_of_uses != task.number_of_uses { + let subaccount = sha2::Sha256::digest(args.task_id.u64().to_bytes()).into(); + final_token_reward + .adjust_reward( + task.creator, + subaccount, + task.token_reward.clone(), + task.number_of_uses, + final_number_of_uses, + already_rewarded, + ) + .await?; + } + + if let Some(new_end_time) = args.end_time { + if new_end_time != task.end_time { + if let Some(timer_id) = task.timer_id.take() { + ic_cdk_timers::clear_timer(TimerId::try_from(timer_id)?); + } + + let new_timer = timer_logic::schedule_expire_task_timer(args.task_id, new_end_time); + task.timer_id = Some(new_timer.into()); + } + } + + let prev_task_count = task.tasks.len(); + task.edit_task(&args, maybe_new_tasks.as_ref()); + if prev_task_count > task.tasks.len() { + if let Err(e) = task.claim_all_rewards(args.task_id).await { + ic_cdk::println!( + "Failed to claim rewards for task {:?}: {:?}", + args.task_id, + e + ); + } + } + + memory::mut_open_task(args.task_id, |maybe_task| { + let task_in_memory = maybe_task + .as_mut() + .ok_or(Error::TaskDoNotExists(args.task_id))?; + *task_in_memory = task.clone(); + Ok(()) + })??; + + let number_of_uses: usize = task + .number_of_uses + .try_into() + .expect("u64 do not fit in usize?"); + + if number_of_uses == task.rewarded.len() { + ic_cdk::println!("All rewards distributed. Closing task {}", args.task_id); + timer_logic::force_expire_task(args.task_id).await?; + } + + Ok(()) +} + +#[update] +pub async fn force_expire_task(task_id: TaskId) -> Result<(), Error> { + parent_or_owner_or_admin_guard().await?; + timer_logic::force_expire_task(task_id).await?; + Ok(()) +} + +#[update] +pub async fn close_task(task_id: TaskId) -> Result<(), Error> { + parent_or_owner_or_admin_guard().await?; + timer_logic::force_close_task(task_id).await?; + Ok(()) +} + +#[update] +pub async fn delete_closed_task(task_id: TaskId) -> Result<(), Error> { + parent_or_owner_or_admin_guard().await?; + let mut closed_task = + memory::get_closed_task(&task_id).ok_or(Error::TaskDoNotExists(task_id))?; + closed_task.claim_all_rewards(task_id).await?; + memory::delete_closed_task(&task_id).expect("Failed to remove closed task"); + Ok(()) +} + +#[update] +pub fn transfer_space(to: Principal) { + parent_guard().unwrap(); + memory::mut_config(|config| config.owner = to); +} + +#[update] +pub async fn clean_up_space_before_deletion() -> Result<(), String> { + parent_guard().map_err(|e| e.to_string())?; + + let open_tasks: Vec<(TaskId, Task)> = memory::get_all_open_tasks(); + for (task_id, _) in &open_tasks { + timer_logic::force_expire_task(*task_id) + .await + .map_err(|e| format!("Failed to expire task {task_id:?}: {e:?}"))?; + } + + let expired_tasks: Vec<(TaskId, Task)> = memory::get_all_expired_tasks(); + for (task_id, _) in &expired_tasks { + timer_logic::force_close_task(*task_id) + .await + .map_err(|e| format!("Failed to close task {task_id:?}: {e:?}"))?; + } + + let mut errors = Vec::new(); + let closed_tasks: Vec<(TaskId, ClosedTask)> = memory::get_all_closed_tasks(); + + for (task_id, mut closed_task) in closed_tasks { + if let Err(err) = closed_task.claim_all_rewards(task_id).await { + ic_cdk::println!("Failed to claim rewards for task {:?}: {:?}", task_id, err); + errors.push(format!("Task {task_id:?}: {err:?}")); + } + } + + if !errors.is_empty() { + return Err(format!( + "Failed to claim rewards for {} tasks: {:?}", + errors.len(), + errors + )); + } + + Ok(()) +} + +#[update] +async fn fetch_x_user_info(access_token: &str) -> Result { + let url = "https://api.twitter.com/2/users/me?user.fields=created_at".to_string(); + + let request_headers = vec![HttpHeader { + name: "Authorization".to_string(), + value: format!("Bearer {}", access_token), + }]; + + let request = HttpRequestArgs { + url: url.clone(), + max_response_bytes: None, + method: HttpMethod::GET, + headers: request_headers, + body: None, + transform: None, + }; + + match http_request(&request).await { + Ok(result) => Ok(String::from_utf8(result.body) + .unwrap_or_else(|_| "Error decoding UTF-8 from X API".to_string())), + Err(e) => { + let message = format!("Error GET /users/me: RejectionCode: {:?}", e); + ic_cdk::println!("{}", &message); + Err(message) + } + } +} + +#[update] +pub async fn exchange_code_for_token( + auth_code: String, + code_verifier: String, +) -> Result { + const CLIENT_ID: &str = env!("PUBLIC_X_CLIENT_ID"); + const CLIENT_SECRET: &str = env!("CLIENT_SECRET"); + const REDIRECT_URI: &str = env!("PUBLIC_X_REDIRECT_URI"); + + let client_id = CLIENT_ID; + let client_secret = CLIENT_SECRET; + let redirect_uri = REDIRECT_URI; + + ic_cdk::println!("Loaded CLIENT_ID: {}", client_id); + ic_cdk::println!("Loaded REDIRECT_URI: {}", redirect_uri); + ic_cdk::println!("Loaded CLIENT_SECRET: {}", &client_secret[..4]); + + let url = "https://api.twitter.com/2/oauth2/token".to_string(); + + let request_headers = vec![ + HttpHeader { + name: "Content-Type".to_string(), + value: "application/x-www-form-urlencoded".to_string(), + }, + HttpHeader { + name: "Authorization".to_string(), + value: format!( + "Basic {}", + base64::engine::general_purpose::STANDARD + .encode(format!("{}:{}", client_id, client_secret)) + ), + }, + ]; + + let request_body_data = format!( + "code={}&grant_type=authorization_code&client_id={}&redirect_uri={}&code_verifier={}", + auth_code, client_id, redirect_uri, code_verifier + ); + let request_body: Option> = Some(request_body_data.into_bytes()); + + let request = HttpRequestArgs { + url: url.clone(), + max_response_bytes: None, + method: HttpMethod::POST, + headers: request_headers, + body: request_body, + transform: None, + }; + + let access_token: String = match http_request(&request).await { + Ok(result) => { + let str_body = String::from_utf8(result.body) + .map_err(|_| "Response body is not valid UTF-8".to_string())?; + let token_data: TwitterTokenResponse = serde_json::from_str(&str_body) + .map_err(|e| format!("Failed to parse JSON response: {}", e))?; + ic_cdk::println!("Received twitter answer: {}", str_body); + token_data.access_token + } + Err(e) => { + let message = format!("HTTP Error during Token Exchange: {:?}", e); + ic_cdk::println!("{}", &message); + return Err(message); + } + }; + match fetch_x_user_info(&access_token).await { + Ok(user_info_json) => Ok(user_info_json), + Err(e) => { + ic_cdk::println!("Fatal Error Fetching User Info: {}", e); + Err(format!("User veryfication failed {}", e)) + } + } +} diff --git a/src/atlas_space/src/tasks/submission.rs b/src/atlas_space/src/tasks/submission.rs index 9a1a67b..ba2ecad 100644 --- a/src/atlas_space/src/tasks/submission.rs +++ b/src/atlas_space/src/tasks/submission.rs @@ -29,6 +29,13 @@ pub enum Submission { #[n(0)] items: Vec, }, + #[n(3)] + Discord { + #[n(0)] + username: String, + #[n(1)] + user_id: u64, + }, } #[derive(Eq, PartialEq, Debug, Decode, Encode, Clone, CandidType)] diff --git a/src/atlas_space/src/tasks/task_types.rs b/src/atlas_space/src/tasks/task_types.rs index 531b267..3354e71 100644 --- a/src/atlas_space/src/tasks/task_types.rs +++ b/src/atlas_space/src/tasks/task_types.rs @@ -55,6 +55,28 @@ pub enum TaskContent { #[n(3)] answer_format: AnswerFormat, }, + #[n(1)] + DiscordTask { + #[n(0)] + task_title: String, + #[n(1)] + task_description: String, + #[n(2)] + guild_id: String, + #[n(3)] + invite_link: String, + #[n(4)] + allow_resubmit: bool, + }, + #[n(2)] + TwitterTask { + #[n(0)] + task_title: String, + #[n(1)] + task_description: String, + #[n(2)] + allow_resubmit: bool, + }, } impl TaskContent { @@ -78,12 +100,58 @@ impl TaskContent { } Ok(()) } + TaskContent::DiscordTask { + task_title, + task_description, + guild_id, + invite_link, + allow_resubmit: _, + } => { + if task_title.trim().len() > 50 { + return Err(Error::InvalidTaskContent( + "Subtask title is too long (max length: 50)".into(), + )); + } + if task_description.trim().len() > 500 { + return Err(Error::InvalidTaskContent( + "Subtask description is too long (max length: 500)".into(), + )); + } + if guild_id.trim().is_empty() { + return Err(Error::InvalidTaskContent("Guild ID cannot be empty".into())); + } + if invite_link.trim().is_empty() { + return Err(Error::InvalidTaskContent( + "Discord invite link cannot be empty".into(), + )); + } + Ok(()) + } + TaskContent::TwitterTask { + task_title, + task_description, + allow_resubmit: _, + } => { + if task_title.trim().len() > 50 { + return Err(Error::InvalidTaskContent( + "Subtask title is too long (max length: 50)".into(), + )); + } + if task_description.trim().len() > 500 { + return Err(Error::InvalidTaskContent( + "Subtask description is too long (max length: 500)".into(), + )); + } + Ok(()) + } } } pub fn allow_resubmit(&self) -> bool { match self { TaskContent::TitleAndDescription { allow_resubmit, .. } => *allow_resubmit, + TaskContent::DiscordTask { allow_resubmit, .. } => *allow_resubmit, + TaskContent::TwitterTask { allow_resubmit, .. } => *allow_resubmit, } } } @@ -105,6 +173,34 @@ impl From<&TaskContent> for TaskType { }, submission: Default::default(), }, + TaskContent::DiscordTask { + task_title, + task_description, + guild_id, + invite_link, + allow_resubmit, + } => Self::DiscordTask { + task_content: TaskContent::DiscordTask { + task_title: task_title.clone(), + task_description: task_description.clone(), + guild_id: guild_id.clone(), + invite_link: invite_link.clone(), + allow_resubmit: *allow_resubmit, + }, + submission: Default::default(), + }, + TaskContent::TwitterTask { + task_title, + task_description, + allow_resubmit, + } => Self::TwitterTask { + task_content: TaskContent::TwitterTask { + task_title: task_title.clone(), + task_description: task_description.clone(), + allow_resubmit: *allow_resubmit, + }, + submission: Default::default(), + }, } } } @@ -118,6 +214,20 @@ pub enum TaskType { #[cbor(n(1), with = "shared::cbor::principal::b_tree_map")] submission: BTreeMap, }, + #[n(1)] + DiscordTask { + #[n(0)] + task_content: TaskContent, + #[cbor(n(1), with = "shared::cbor::principal::b_tree_map")] + submission: BTreeMap, + }, + #[n(2)] + TwitterTask { + #[n(0)] + task_content: TaskContent, + #[cbor(n(1), with = "shared::cbor::principal::b_tree_map")] + submission: BTreeMap, + }, } impl TaskType { @@ -126,96 +236,132 @@ impl TaskType { match self { TaskType::GenericTask { task_content, - submission: submissions_map, - } => { - if let Some(existing_submission) = submissions_map.get(&user) { - if existing_submission.get_state() == &SubmissionState::Rejected - && allow_resubmit - { - submissions_map.remove(&user); - } else { - return Err(Error::UserAlreadySubmitted); + submission: _, + } => match (task_content, &submission) { + ( + TaskContent::TitleAndDescription { answer_format, .. }, + Submission::Text { content }, + ) => { + let len = content.trim().len(); + match answer_format { + AnswerFormat::Small if len > 254 => { + return Err(Error::IncorrectSubmission( + "Answer too long (max 254 chars)".into(), + )) + } + AnswerFormat::Paragraph if len > 600 => { + return Err(Error::IncorrectSubmission( + "Answer too long (max 600 chars)".into(), + )) + } + AnswerFormat::Long if len > 2500 => { + return Err(Error::IncorrectSubmission( + "Answer too long (max 2500 chars)".into(), + )) + } + AnswerFormat::List => { + return Err(Error::IncorrectSubmission( + "Expected list submission".into(), + )) + } + _ => {} } } - match (task_content, &submission) { - ( - TaskContent::TitleAndDescription { answer_format, .. }, - Submission::Text { content }, - ) => { - let len = content.trim().len(); - match answer_format { - AnswerFormat::Small if len > 254 => { - return Err(Error::IncorrectSubmission( - "Answer too long (max 254 chars)".into(), - )) - } - AnswerFormat::Paragraph if len > 600 => { - return Err(Error::IncorrectSubmission( - "Answer too long (max 600 chars)".into(), - )) - } - AnswerFormat::Long if len > 2500 => { - return Err(Error::IncorrectSubmission( - "Answer too long (max 2500 chars)".into(), - )) - } - AnswerFormat::List => { - return Err(Error::IncorrectSubmission( - "Expected list submission".into(), - )) - } - _ => {} - } + ( + TaskContent::TitleAndDescription { answer_format, .. }, + Submission::List { items }, + ) => { + if *answer_format != AnswerFormat::List { + return Err(Error::IncorrectSubmission( + "Expected text submission".into(), + )); + } + if items.is_empty() { + return Err(Error::IncorrectSubmission("List cannot be empty".into())); + } + if items.len() > 25 { + return Err(Error::IncorrectSubmission( + "List too long (max 25 items)".into(), + )); } - ( - TaskContent::TitleAndDescription { answer_format, .. }, - Submission::List { items }, - ) => { - if *answer_format != AnswerFormat::List { + for item in items { + if item.trim().is_empty() { return Err(Error::IncorrectSubmission( - "Expected text submission".into(), + "List item cannot be empty".into(), )); } - if items.is_empty() { - return Err(Error::IncorrectSubmission("List cannot be empty".into())); - } - if items.len() > 25 { + if item.len() > 254 { return Err(Error::IncorrectSubmission( - "List too long (max 25 items)".into(), + "List item too long (max 254 chars)".into(), )); } - for item in items { - if item.trim().is_empty() { - return Err(Error::IncorrectSubmission( - "List item cannot be empty".into(), - )); - } - if item.len() > 254 { - return Err(Error::IncorrectSubmission( - "List item too long (max 254 chars)".into(), - )); - } - } } - _ => { - return Err(Error::IncorrectSubmission( - "Unsupported submission type".into(), - )) + } + _ => { + return Err(Error::IncorrectSubmission( + "Unsupported submission type".into(), + )) + } + }, + TaskType::DiscordTask { .. } => { + if let Submission::Discord { username, user_id } = &submission { + if username.trim().is_empty() || *user_id == 0 { + return Err(Error::InvalidTaskContent( + "Submission cannot be empty".into(), + )); } + } else { + return Err(Error::IncorrectSubmission("Discord".to_string())); } - submissions_map.insert( - user, - SubmissionData::new(submission, SubmissionState::default()), - ); - Ok(()) + } + TaskType::TwitterTask { .. } => { + if let Submission::Text { content } = &submission { + if content.trim().is_empty() { + return Err(Error::InvalidTaskContent( + "Submission cannot be empty".into(), + )); + } + } else { + return Err(Error::IncorrectSubmission("Twitter".to_string())); + } + } + }; + + let submissions_map = match self { + TaskType::GenericTask { submission, .. } + | TaskType::DiscordTask { submission, .. } + | TaskType::TwitterTask { submission, .. } => submission, + }; + + if let Some(existing_submission) = submissions_map.get(&user) { + if existing_submission.get_state() == &SubmissionState::Rejected && allow_resubmit { + submissions_map.remove(&user); + } else { + return Err(Error::UserAlreadySubmitted); } } + + submissions_map.insert( + user, + SubmissionData::new(submission, SubmissionState::default()), + ); + + Ok(()) } + pub fn accept(&mut self, user: Principal) -> Result<(), Error> { match self { TaskType::GenericTask { task_content: _, submission: submissions_map, + } + | TaskType::DiscordTask { + task_content: _, + submission: submissions_map, + } + | TaskType::TwitterTask { + task_content: _, + submission: submissions_map, } => { let submission = submissions_map .get_mut(&user) @@ -232,6 +378,14 @@ impl TaskType { TaskType::GenericTask { task_content: _, submission: submissions_map, + } + | TaskType::DiscordTask { + task_content: _, + submission: submissions_map, + } + | TaskType::TwitterTask { + task_content: _, + submission: submissions_map, } => { let submission = submissions_map .get_mut(&user) @@ -264,24 +418,32 @@ impl TaskType { pub fn get_submission_map(&self) -> &BTreeMap { match self { TaskType::GenericTask { submission, .. } => submission, + TaskType::DiscordTask { submission, .. } => submission, + TaskType::TwitterTask { submission, .. } => submission, } } pub fn get_submission_map_mut(&mut self) -> &mut BTreeMap { match self { TaskType::GenericTask { submission, .. } => submission, + TaskType::DiscordTask { submission, .. } => submission, + TaskType::TwitterTask { submission, .. } => submission, } } pub fn get_allow_resubmit(&self) -> bool { match self { TaskType::GenericTask { task_content, .. } => task_content.allow_resubmit(), + TaskType::DiscordTask { task_content, .. } => task_content.allow_resubmit(), + TaskType::TwitterTask { task_content, .. } => task_content.allow_resubmit(), } } pub fn get_content(&self) -> &TaskContent { match self { TaskType::GenericTask { task_content, .. } => task_content, + TaskType::DiscordTask { task_content, .. } => task_content, + TaskType::TwitterTask { task_content, .. } => task_content, } } } @@ -321,3 +483,12 @@ impl Storable for TaskId { const BOUND: Bound = Bound::Unbounded; } + +#[derive(Deserialize)] +pub struct TwitterTokenResponse { + pub token_type: String, + pub expires_in: u64, + pub access_token: String, + pub refresh_token: String, + pub scope: String, +} diff --git a/src/declarations/atlas_space/atlas_space.did b/src/declarations/atlas_space/atlas_space.did index a36d106..de5ad31 100644 --- a/src/declarations/atlas_space/atlas_space.did +++ b/src/declarations/atlas_space/atlas_space.did @@ -88,8 +88,9 @@ type GetTasksRes = record { type Result = variant { Ok; Err : Error }; type Result_1 = variant { Ok; Err : text }; type Result_2 = variant { Ok : nat64; Err : Error }; -type Result_3 = variant { Ok : GetClosedTasksRes; Err : Error }; -type Result_4 = variant { Ok : GetTasksRes; Err : Error }; +type Result_3 = variant { Ok : text; Err : text }; +type Result_4 = variant { Ok : GetClosedTasksRes; Err : Error }; +type Result_5 = variant { Ok : GetTasksRes; Err : Error }; type SpaceArgs = variant { UpgradeArg : record { version : nat64 }; InitArg : SpaceInitArg; @@ -119,6 +120,7 @@ type Submission = variant { Empty; List : record { items : vec text }; Text : record { content : text }; + Discord : record { username : text; user_id : nat64 }; }; type SubmissionData = record { state : SubmissionState; @@ -138,6 +140,18 @@ type Task = record { number_of_uses : nat64; }; type TaskContent = variant { + TwitterTask : record { + task_description : text; + task_title : text; + allow_resubmit : bool; + }; + DiscordTask : record { + task_description : text; + task_title : text; + invite_link : text; + guild_id : text; + allow_resubmit : bool; + }; TitleAndDescription : record { answer_format : AnswerFormat; task_description : text; @@ -146,6 +160,14 @@ type TaskContent = variant { }; }; type TaskType = variant { + TwitterTask : record { + task_content : TaskContent; + submission : vec record { principal; SubmissionData }; + }; + DiscordTask : record { + task_content : TaskContent; + submission : vec record { principal; SubmissionData }; + }; GenericTask : record { task_content : TaskContent; submission : vec record { principal; SubmissionData }; @@ -161,12 +183,14 @@ service : (SpaceArgs) -> { delete_closed_task : (nat64) -> (Result); edit_space : (EditSpaceArgs) -> (Result); edit_task : (EditTaskArgs) -> (Result); + exchange_code_for_token : (text, text) -> (Result_3); + fetch_x_user_info : (text) -> (Result_3); force_expire_task : (nat64) -> (Result); - get_closed_tasks : (GetTasksArgs) -> (Result_3) query; + get_closed_tasks : (GetTasksArgs) -> (Result_4) query; get_config : () -> (Config) query; get_current_bytecode_version : () -> (nat64) query; - get_expired_tasks : (GetTasksArgs) -> (Result_4) query; - get_open_tasks : (GetTasksArgs) -> (Result_4) query; + get_expired_tasks : (GetTasksArgs) -> (Result_5) query; + get_open_tasks : (GetTasksArgs) -> (Result_5) query; get_space_info : () -> (SpaceInfo) query; get_state : () -> (State) query; reject_subtask_submission : (principal, nat64, nat64, opt text) -> (Result); diff --git a/src/declarations/atlas_space/atlas_space.did.d.ts b/src/declarations/atlas_space/atlas_space.did.d.ts index 92b2d9c..dd10ee9 100644 --- a/src/declarations/atlas_space/atlas_space.did.d.ts +++ b/src/declarations/atlas_space/atlas_space.did.d.ts @@ -99,9 +99,11 @@ export type Result_1 = { 'Ok' : null } | { 'Err' : string }; export type Result_2 = { 'Ok' : bigint } | { 'Err' : Error }; -export type Result_3 = { 'Ok' : GetClosedTasksRes } | +export type Result_3 = { 'Ok' : string } | + { 'Err' : string }; +export type Result_4 = { 'Ok' : GetClosedTasksRes } | { 'Err' : Error }; -export type Result_4 = { 'Ok' : GetTasksRes } | +export type Result_5 = { 'Ok' : GetTasksRes } | { 'Err' : Error }; export type SpaceArgs = { 'UpgradeArg' : { 'version' : bigint } } | { 'InitArg' : SpaceInitArg }; @@ -128,7 +130,8 @@ export interface State { } export type Submission = { 'Empty' : null } | { 'List' : { 'items' : Array } } | - { 'Text' : { 'content' : string } }; + { 'Text' : { 'content' : string } } | + { 'Discord' : { 'username' : string, 'user_id' : bigint } }; export interface SubmissionData { 'state' : SubmissionState, 'rejection_reason' : [] | [string], @@ -149,6 +152,22 @@ export interface Task { 'number_of_uses' : bigint, } export type TaskContent = { + 'TwitterTask' : { + 'task_description' : string, + 'task_title' : string, + 'allow_resubmit' : boolean, + } + } | + { + 'DiscordTask' : { + 'task_description' : string, + 'task_title' : string, + 'invite_link' : string, + 'guild_id' : string, + 'allow_resubmit' : boolean, + } + } | + { 'TitleAndDescription' : { 'answer_format' : AnswerFormat, 'task_description' : string, @@ -157,6 +176,18 @@ export type TaskContent = { } }; export type TaskType = { + 'TwitterTask' : { + 'task_content' : TaskContent, + 'submission' : Array<[Principal, SubmissionData]>, + } + } | + { + 'DiscordTask' : { + 'task_content' : TaskContent, + 'submission' : Array<[Principal, SubmissionData]>, + } + } | + { 'GenericTask' : { 'task_content' : TaskContent, 'submission' : Array<[Principal, SubmissionData]>, @@ -175,12 +206,14 @@ export interface _SERVICE { 'delete_closed_task' : ActorMethod<[bigint], Result>, 'edit_space' : ActorMethod<[EditSpaceArgs], Result>, 'edit_task' : ActorMethod<[EditTaskArgs], Result>, + 'exchange_code_for_token' : ActorMethod<[string, string], Result_3>, + 'fetch_x_user_info' : ActorMethod<[string], Result_3>, 'force_expire_task' : ActorMethod<[bigint], Result>, - 'get_closed_tasks' : ActorMethod<[GetTasksArgs], Result_3>, + 'get_closed_tasks' : ActorMethod<[GetTasksArgs], Result_4>, 'get_config' : ActorMethod<[], Config>, 'get_current_bytecode_version' : ActorMethod<[], bigint>, - 'get_expired_tasks' : ActorMethod<[GetTasksArgs], Result_4>, - 'get_open_tasks' : ActorMethod<[GetTasksArgs], Result_4>, + 'get_expired_tasks' : ActorMethod<[GetTasksArgs], Result_5>, + 'get_open_tasks' : ActorMethod<[GetTasksArgs], Result_5>, 'get_space_info' : ActorMethod<[], SpaceInfo>, 'get_state' : ActorMethod<[], State>, 'reject_subtask_submission' : ActorMethod< diff --git a/src/declarations/atlas_space/atlas_space.did.js b/src/declarations/atlas_space/atlas_space.did.js index 8575902..15d7479 100644 --- a/src/declarations/atlas_space/atlas_space.did.js +++ b/src/declarations/atlas_space/atlas_space.did.js @@ -67,6 +67,18 @@ export const idlFactory = ({ IDL }) => { 'Paragraph' : IDL.Null, }); const TaskContent = IDL.Variant({ + 'TwitterTask' : IDL.Record({ + 'task_description' : IDL.Text, + 'task_title' : IDL.Text, + 'allow_resubmit' : IDL.Bool, + }), + 'DiscordTask' : IDL.Record({ + 'task_description' : IDL.Text, + 'task_title' : IDL.Text, + 'invite_link' : IDL.Text, + 'guild_id' : IDL.Text, + 'allow_resubmit' : IDL.Bool, + }), 'TitleAndDescription' : IDL.Record({ 'answer_format' : AnswerFormat, 'task_description' : IDL.Text, @@ -99,6 +111,7 @@ export const idlFactory = ({ IDL }) => { 'start_time' : IDL.Opt(IDL.Nat64), 'number_of_uses' : IDL.Opt(IDL.Nat64), }); + const Result_3 = IDL.Variant({ 'Ok' : IDL.Text, 'Err' : IDL.Text }); const GetTasksArgs = IDL.Record({ 'count' : IDL.Nat64, 'start' : IDL.Nat64 }); const SubmissionState = IDL.Variant({ 'Rejected' : IDL.Null, @@ -109,6 +122,7 @@ export const idlFactory = ({ IDL }) => { 'Empty' : IDL.Null, 'List' : IDL.Record({ 'items' : IDL.Vec(IDL.Text) }), 'Text' : IDL.Record({ 'content' : IDL.Text }), + 'Discord' : IDL.Record({ 'username' : IDL.Text, 'user_id' : IDL.Nat64 }), }); const SubmissionData = IDL.Record({ 'state' : SubmissionState, @@ -116,6 +130,14 @@ export const idlFactory = ({ IDL }) => { 'submission' : Submission, }); const TaskType = IDL.Variant({ + 'TwitterTask' : IDL.Record({ + 'task_content' : TaskContent, + 'submission' : IDL.Vec(IDL.Tuple(IDL.Principal, SubmissionData)), + }), + 'DiscordTask' : IDL.Record({ + 'task_content' : TaskContent, + 'submission' : IDL.Vec(IDL.Tuple(IDL.Principal, SubmissionData)), + }), 'GenericTask' : IDL.Record({ 'task_content' : TaskContent, 'submission' : IDL.Vec(IDL.Tuple(IDL.Principal, SubmissionData)), @@ -136,7 +158,7 @@ export const idlFactory = ({ IDL }) => { 'tasks' : IDL.Vec(IDL.Tuple(IDL.Nat64, ClosedTask)), 'tasks_count' : IDL.Nat64, }); - const Result_3 = IDL.Variant({ 'Ok' : GetClosedTasksRes, 'Err' : Error }); + const Result_4 = IDL.Variant({ 'Ok' : GetClosedTasksRes, 'Err' : Error }); const CkUsdcLedger = IDL.Record({ 'fee' : IDL.Opt(IDL.Nat), 'principal' : IDL.Principal, @@ -162,7 +184,7 @@ export const idlFactory = ({ IDL }) => { 'tasks' : IDL.Vec(IDL.Tuple(IDL.Nat64, Task)), 'tasks_count' : IDL.Nat64, }); - const Result_4 = IDL.Variant({ 'Ok' : GetTasksRes, 'Err' : Error }); + const Result_5 = IDL.Variant({ 'Ok' : GetTasksRes, 'Err' : Error }); const State = IDL.Record({ 'external_links' : IDL.Vec(IDL.Tuple(IDL.Text, IDL.Text)), 'space_symbol' : IDL.Opt(IDL.Text), @@ -186,12 +208,14 @@ export const idlFactory = ({ IDL }) => { 'delete_closed_task' : IDL.Func([IDL.Nat64], [Result], []), 'edit_space' : IDL.Func([EditSpaceArgs], [Result], []), 'edit_task' : IDL.Func([EditTaskArgs], [Result], []), + 'exchange_code_for_token' : IDL.Func([IDL.Text, IDL.Text], [Result_3], []), + 'fetch_x_user_info' : IDL.Func([IDL.Text], [Result_3], []), 'force_expire_task' : IDL.Func([IDL.Nat64], [Result], []), - 'get_closed_tasks' : IDL.Func([GetTasksArgs], [Result_3], ['query']), + 'get_closed_tasks' : IDL.Func([GetTasksArgs], [Result_4], ['query']), 'get_config' : IDL.Func([], [Config], ['query']), 'get_current_bytecode_version' : IDL.Func([], [IDL.Nat64], ['query']), - 'get_expired_tasks' : IDL.Func([GetTasksArgs], [Result_4], ['query']), - 'get_open_tasks' : IDL.Func([GetTasksArgs], [Result_4], ['query']), + 'get_expired_tasks' : IDL.Func([GetTasksArgs], [Result_5], ['query']), + 'get_open_tasks' : IDL.Func([GetTasksArgs], [Result_5], ['query']), 'get_space_info' : IDL.Func([], [SpaceInfo], ['query']), 'get_state' : IDL.Func([], [State], ['query']), 'reject_subtask_submission' : IDL.Func( From d729aa372571f0a8375bdd033a9c3b1621613a1d Mon Sep 17 00:00:00 2001 From: odzioo123 <1konrad.sadowski@gmail.com> Date: Tue, 28 Oct 2025 21:16:38 +0100 Subject: [PATCH 02/11] work in progress --- .../src/modals/CreateNewTaskModal.tsx | 188 ++++++++++++++---- src/atlas_frontend/src/utils/taskMapper.ts | 21 +- 2 files changed, 166 insertions(+), 43 deletions(-) diff --git a/src/atlas_frontend/src/modals/CreateNewTaskModal.tsx b/src/atlas_frontend/src/modals/CreateNewTaskModal.tsx index 0f1cd9d..347d2f1 100644 --- a/src/atlas_frontend/src/modals/CreateNewTaskModal.tsx +++ b/src/atlas_frontend/src/modals/CreateNewTaskModal.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useEffect } from "react"; +import React, { useRef, useEffect, useState } from "react"; import { yupResolver } from "@hookform/resolvers/yup"; import { useForm, useFieldArray, type SubmitHandler, type FieldErrorsImpl } from "react-hook-form"; import Button from "../components/Shared/Button"; @@ -49,6 +49,9 @@ import { selectUserBlockchainData, type StorableUser, } from "../store/slices/userSlice"; +import DiscordTask from "./tasks/DiscordTask"; +import TwitterTask from "./tasks/TwitterTask"; +import { mapTasks, TaskType, type TaskInput } from "../utils/taskMapper"; export const getAnswerFormatKey = (format: AnswerFormat): string => Object.keys(format)[0]; const answerFormatDescriptions: Record = { @@ -58,9 +61,6 @@ const answerFormatDescriptions: Record = { List: "Multiple items, each up to 254 characters", }; -type TaskType = "generic"; -const allowedTaskTypes = ["generic"] as const; - interface CreateNewTaskFormInput { numberOfUses: number; rewardPerUsage: number; @@ -71,8 +71,10 @@ interface CreateNewTaskFormInput { taskType: TaskType; title: string; description: string; + guildId?: string; + inviteLink?: string; allowResubmit: boolean; - answerFormat: keyof typeof answerFormatDescriptions; + answerFormat?: keyof typeof answerFormatDescriptions; } | { disabled: boolean })[]; } @@ -97,7 +99,7 @@ const answerFormatKeys = Object.keys(answerFormatDescriptions) as Array< >; const taskSchema = yup.object({ - taskType: yup.mixed().oneOf(allowedTaskTypes).required(), + taskType: yup.mixed().oneOf(Object.values(TaskType)).required(), title: yup .string() .trim() @@ -111,6 +113,27 @@ const taskSchema = yup.object({ .min(2) .required() .label("Task description"), + guildId: yup + .string() + .when("taskType", { + is: (value: TaskType) => value === TaskType.Discord, + then: (schema: yup.StringSchema) => + schema + .typeError("Guild ID must be a valid string") + .required("Guild ID is required for Discord tasks"), + }) + .label("Guild ID"), + inviteLink: yup + .string() + .when("taskType", { + is: (value: TaskType) => value === TaskType.Discord, + then: (schema: yup.StringSchema) => + schema + .trim() + .matches(/^(https?:\/\/)?(www\.)?discord\.(gg|com\/invite)\/[a-zA-Z0-9-]+$/, "Invalid invite link format") + .required("Invite link is required for Discord tasks"), + }) + .label("Invite Link"), allowResubmit: yup.boolean().required(), answerFormat: yup .string() @@ -119,13 +142,56 @@ const taskSchema = yup.object({ .label("Answer format"), }); +const genericTaskSchema = yup.object({ + taskType: yup.mixed().oneOf([TaskType.Generic]).required(), + title: yup.string().trim().max(maxSubtitleLength).required(), + description: yup.string().trim().max(maxDescriptionLength).required(), + allowResubmit: yup.boolean().required(), + answerFormat: yup.string().oneOf(answerFormatKeys).required(), +}); + +const twitterTaskSchema = yup.object({ + taskType: yup.mixed().oneOf([TaskType.Twitter]).required(), + title: yup.string().trim().max(maxSubtitleLength).required(), + description: yup.string().trim().max(maxDescriptionLength).required(), + tweetUrl: yup + .string() + .url("Must be a valid Twitter post URL") + .required("Twitter post URL is required"), + allowResubmit: yup.boolean().required(), +}); + +const discordTaskSchema = yup.object({ + taskType: yup.mixed().oneOf([TaskType.Discord]).required(), + title: yup.string().trim().max(maxSubtitleLength).required(), + description: yup.string().trim().max(maxDescriptionLength).required(), + guildId: yup + .string() + .required("Guild ID is required for Discord tasks"), + inviteLink: yup + .string() + .matches( + /^(https?:\/\/)?(www\.)?discord\.(gg|com\/invite)\/[a-zA-Z0-9-]+$/, + "Invalid Discord invite link" + ) + .required("Invite link is required for Discord tasks"), + allowResubmit: yup.boolean().required(), +}); + const taskOrDisabledSchema = yup.lazy((value) => { if (value && "disabled" in value) { - return yup.object({ - disabled: yup.boolean().required(), - }); + return yup.object({ disabled: yup.boolean().required() }); + } + + switch (value?.taskType) { + case TaskType.Twitter: + return twitterTaskSchema; + case TaskType.Discord: + return discordTaskSchema; + case TaskType.Generic: + default: + return genericTaskSchema; } - return taskSchema; }); export interface EditableTask extends Task { @@ -137,7 +203,8 @@ const CreateNewTaskModal = () => { const navigate = useNavigate(); const { pathname } = useLocation(); const title = pathname.endsWith("/edit") ? "Edit mission" : "Create new mission"; - + const [isInviteValid, setInviteValid] = useState(false); + const principal = useSpaceId({ spacePrincipal, navigate, @@ -234,7 +301,7 @@ const CreateNewTaskModal = () => { endTime: '', tasks: [ { - taskType: "generic", + taskType: TaskType.Generic, title: "", description: "", allowResubmit: false, @@ -345,33 +412,37 @@ const CreateNewTaskModal = () => { return; } - const toAnswerFormat = (key: string): AnswerFormat => { - switch (key) { - case "Small": - return { Small: null }; - case "Paragraph": - return { Paragraph: null }; - case "Long": - return { Long: null }; - case "List": - return { List: null }; - default: - throw new Error(`Unknown AnswerFormat key: ${key}`); - } - }; - - const taskContent = tasks.map((task) => { - if ("disabled" in task) { - return null; - } - return { - task_type: "generic", - title: task.title, - description: task.description, - allow_resubmit: task.allowResubmit, - answer_format: toAnswerFormat(task.answerFormat), - }; - }); + // const toAnswerFormat = (key: string): AnswerFormat => { + // switch (key) { + // case "Small": + // return { Small: null }; + // case "Paragraph": + // return { Paragraph: null }; + // case "Long": + // return { Long: null }; + // case "List": + // return { List: null }; + // default: + // throw new Error(`Unknown AnswerFormat key: ${key}`); + // } + // }; + + // const taskContent = tasks.map((task) => { + // if ("disabled" in task) { + // return null; + // } + // return { + // task_type: "generic", + // title: task.title, + // description: task.description, + // allow_resubmit: task.allowResubmit, + // answer_format: toAnswerFormat(task.answerFormat), + // }; + // }); + const taskContent = mapTasks( + (tasks ?? []) + .filter((t): t is TaskInput => "taskType" in t) + ); if (!taskContent || taskContent.length === 0) { toast.error("Invalid subtasks: the minimum number of subtasks is one."); @@ -790,9 +861,41 @@ const CreateNewTaskModal = () => { > Generic Task + +
+ {currentTask.taskType === TaskType.Discord && ( + + )} + {currentTask.taskType === TaskType.Twitter && ( + + )}
{ className="text-white bg-white/20 px-3 rounded font-semibold text-sm sm:text-base w-full sm:w-auto mb-2 sm:mb-0" onClick={() => append({ - taskType: "generic", + taskType: TaskType.Generic, title: "", description: "", allowResubmit: false, @@ -853,6 +956,11 @@ const CreateNewTaskModal = () => { */} - )} - {canSubmit && accessToken && !isJoined && inviteLink && ( - + + {!user && ( +
+ +
)} + +
+ {canSubmit && !accessToken && ( + + )} + {canSubmit && accessToken && !isJoined && inviteLink && ( + + )} +
+ {canSubmit && accessToken && isJoined && ( - )} - {!user && ( -
- + + {showRejectionReason && ( +
+

Reject Reason:

+

{reasonText}

)} + + {isUserAdmin && + allSubmissions.length > 0 && + authAtlasSpace && + unAuthAtlasSpace && ( +
+ + {openReview && ( +
+ {allSubmissions.map(([principal, submissionData]) => { + const rowState = Object.keys(submissionData.state ?? {})[0]; + return ( +
+
+ copyPrincipal(principal.toString())} + > + User: {shortPrincipal(principal.toString())}{" "} + + + + {rowState} + +
+ { + getSpaceTasks({ + spaceId: spacePrincipal.toString(), + unAuthAtlasSpace, + dispatch, + }); + }} + /> +
+ ); + })} +
+ )} +
+ )}
); }; -export default DiscordTask; \ No newline at end of file +export default DiscordTask; diff --git a/src/atlas_frontend/src/components/Task/tasks/GenericTask.tsx b/src/atlas_frontend/src/components/Task/tasks/GenericTask.tsx index 6e15e91..5d87247 100644 --- a/src/atlas_frontend/src/components/Task/tasks/GenericTask.tsx +++ b/src/atlas_frontend/src/components/Task/tasks/GenericTask.tsx @@ -30,7 +30,10 @@ import { FaCaretRight } from "react-icons/fa6"; import { shortPrincipal } from "../../../utils/icp"; import { FiCopy } from "react-icons/fi"; -type GenericTaskType = Extract['GenericTask']; +type GenericTaskType = Extract< + TaskType, + { GenericTask: unknown } +>["GenericTask"]; interface GenericTaskProps { genericTask: GenericTaskType; @@ -66,13 +69,13 @@ const GenericTask = ({ const dispatch = useDispatch(); const { user, connect } = useAuth(); const [openSubmission, setSubmission] = useState(false); - const answerFormatKey = "TitleAndDescription" in genericTask.task_content - ? (Object.keys(genericTask.task_content.TitleAndDescription.answer_format)[0] as - | "Small" - | "Paragraph" - | "Long" - | "List") - : null; + const [openReview, SetReview] = useState(false); + const answerFormatKey = + "TitleAndDescription" in genericTask.task_content + ? (Object.keys( + genericTask.task_content.TitleAndDescription.answer_format + )[0] as "Small" | "Paragraph" | "Long" | "List") + : null; const maxTextLength = useMemo(() => { switch (answerFormatKey) { @@ -99,71 +102,77 @@ const GenericTask = ({ const textForm = useForm({ resolver: yupResolver(textSchema), defaultValues: { - taskSubmission: "" + taskSubmission: "", }, }); - const [openReview, SetReview] = useState(false); - + const userBlockchainData = deserialize( useSelector(selectUserBlockchainData) ); const userInfo = userBlockchainData ? new BlockchainUser(userBlockchainData) : null; - const isUserAdmin = isAdmin || (userInfo?.canAdministrate(spacePrincipal) ?? false); + const isUserAdmin = + isAdmin || (userInfo?.canAdministrate(spacePrincipal) ?? false); const copyPrincipal = (user: string) => { navigator.clipboard.writeText(user); toast.success("Copied full principal"); }; - const onSubmitText: SubmitHandler = async ({ taskSubmission }) => { + const onSubmitText: SubmitHandler = async ({ + taskSubmission, + }) => { if (!authAtlasSpace || !unAuthAtlasSpace) return; - await runWithLoading(async () => { - const call = submitSubtaskSubmission({ - authAtlasSpace, - taskId: BigInt(taskId), - subtaskId: BigInt(subtaskId), - submission: { Text: { content: taskSubmission } }, - }); - await toast.promise(call, { - loading: "Submitting response...", - success: "Submitted response.", - error: getErrorWithInfoToast("Failed to submit response."), - }); - - setSubmission(false); - await getSpaceTasks({ - spaceId: spacePrincipal.toString(), - unAuthAtlasSpace, - dispatch, - }); - }, dispatch, () => setSubmission(false)); + await runWithLoading( + async () => { + const call = submitSubtaskSubmission({ + authAtlasSpace, + taskId: BigInt(taskId), + subtaskId: BigInt(subtaskId), + submission: { Text: { content: taskSubmission } }, + }); + await toast.promise(call, { + loading: "Submitting response...", + success: "Submitted response.", + error: getErrorWithInfoToast("Failed to submit response."), + }); + + setSubmission(false); + await getSpaceTasks({ + spaceId: spacePrincipal.toString(), + unAuthAtlasSpace, + dispatch, + }); + }, + dispatch, + () => setSubmission(false) + ); }; const listSchema = yup.object({ - items: yup - .array() - .of( - yup.object({ - value: yup - .string() - .trim() - .max(254, "Item too long") - .required("Item cannot be empty"), - }) - ) - .max(25, "Max 25 items allowed") - .required(), -}); - -const listForm = useForm({ - resolver: yupResolver(listSchema), - defaultValues: { - items: [{ value: "" }] - }, -}); + items: yup + .array() + .of( + yup.object({ + value: yup + .string() + .trim() + .max(254, "Item too long") + .required("Item cannot be empty"), + }) + ) + .max(25, "Max 25 items allowed") + .required(), + }); + + const listForm = useForm({ + resolver: yupResolver(listSchema), + defaultValues: { + items: [{ value: "" }], + }, + }); const { fields, append, remove } = useFieldArray({ control: listForm.control, @@ -173,49 +182,56 @@ const listForm = useForm({ const onSubmitList = async () => { if (!authAtlasSpace || !unAuthAtlasSpace) return; - const items = listForm.getValues().items.map(item => item.value.trim()); - await runWithLoading(async () => { - const call = submitSubtaskSubmission({ - authAtlasSpace, - taskId: BigInt(taskId), - subtaskId: BigInt(subtaskId), - submission: { List: { items } }, - }); - await toast.promise(call, { - loading: "Submitting list...", - success: "Submitted response.", - error: getErrorWithInfoToast("Failed to submit response."), - }); - - setSubmission(false); - await getSpaceTasks({ - spaceId: spacePrincipal.toString(), - unAuthAtlasSpace, - dispatch, - }); - }, dispatch, () => setSubmission(false)); + const items = listForm.getValues().items.map((item) => item.value.trim()); + await runWithLoading( + async () => { + const call = submitSubtaskSubmission({ + authAtlasSpace, + taskId: BigInt(taskId), + subtaskId: BigInt(subtaskId), + submission: { List: { items } }, + }); + await toast.promise(call, { + loading: "Submitting list...", + success: "Submitted response.", + error: getErrorWithInfoToast("Failed to submit response."), + }); + + setSubmission(false); + await getSpaceTasks({ + spaceId: spacePrincipal.toString(), + unAuthAtlasSpace, + dispatch, + }); + }, + dispatch, + () => setSubmission(false) + ); }; const [, submissionData] = user?.principal - ? (genericTask.submission.find( + ? genericTask.submission.find( ([principal]) => principal.toString() === user.principal.toString() - ) ?? []) + ) ?? [] : []; const allSubmissions = genericTask.submission; const currentSubmissionState = submissionData?.state - ? Object.keys(submissionData?.state)[0] - : null; + ? Object.keys(submissionData?.state)[0] + : null; + + const canSubmit = + user && + isUserInHub && + (currentSubmissionState === null || + (currentSubmissionState === "Rejected" && + ("TitleAndDescription" in genericTask.task_content + ? genericTask.task_content.TitleAndDescription.allow_resubmit + : "N/A"))); - const canSubmit = user && isUserInHub && ( - currentSubmissionState === null || - (currentSubmissionState === "Rejected" && ("TitleAndDescription" in genericTask.task_content - ? genericTask.task_content.TitleAndDescription.allow_resubmit : "N/A")) - ); - const rawState = Object.keys(submissionData?.state || {})[0] ?? null; const validStates = ["Rejected", "WaitingForReview", "Accepted"] as const; - type SubmissionState = typeof validStates[number]; + type SubmissionState = (typeof validStates)[number]; const STATUS_LABELS: Record = { WaitingForReview: "Waiting for review", @@ -228,13 +244,15 @@ const listForm = useForm({ const submissionState = validStates.includes(rawState as SubmissionState) ? (rawState as SubmissionState) : null; - + const badgeCls = (s?: string) => - ({ - Accepted: "bg-green-500/20 text-green-300 border border-green-500/30", - Rejected: "bg-red-500/20 text-red-300 border border-red-500/30", - WaitingForReview: "bg-primary/20 border border-white/10", - } as Record)[s ?? ""]; + (( + { + Accepted: "bg-green-500/20 text-green-300 border border-green-500/30", + Rejected: "bg-red-500/20 text-red-300 border border-red-500/30", + WaitingForReview: "bg-primary/20 border border-white/10", + } as Record + )[s ?? ""]); const userState = Object.keys(submissionData?.state ?? {})[0]; @@ -243,15 +261,21 @@ const listForm = useForm({ el.style.height = `${el.scrollHeight}px`; }; - return ( + return (
{submissionState === "WaitingForReview" && ( - + )} {submissionState === "Accepted" && ( - + )}
@@ -259,12 +283,16 @@ const listForm = useForm({

- {"TitleAndDescription" in genericTask.task_content + {"TitleAndDescription" in genericTask.task_content ? genericTask.task_content.TitleAndDescription.task_title : "N/A"}

{user && !isUserAdmin && submissionData && ( - + {prettyStatus} )} @@ -272,19 +300,23 @@ const listForm = useForm({
{user && !isUserAdmin && submissionData && (
- + {prettyStatus}
)}

- {"TitleAndDescription" in genericTask.task_content + {"TitleAndDescription" in genericTask.task_content ? genericTask.task_content.TitleAndDescription.task_title : "N/A"}

- {"TitleAndDescription" in genericTask.task_content + {"TitleAndDescription" in genericTask.task_content ? genericTask.task_content.TitleAndDescription.task_description : "N/A"}

@@ -293,9 +325,11 @@ const listForm = useForm({ {canSubmit && openSubmission && !disabled && ( <> {answerFormatKey !== "List" ? ( -
-
-

Submit response:

+ +
+

+ Submit response: +

-
-
- -
-
- + +
)} - {canSubmit && !openSubmission && ( -
-
)} + {!user && (
)} + + {isUserAdmin && + allSubmissions.length > 0 && + authAtlasSpace && + unAuthAtlasSpace && ( +
+ + {openReview && ( +
+ {allSubmissions.map(([principal, submissionData]) => { + const rowState = Object.keys(submissionData.state ?? {})[0]; + return ( +
+
+ copyPrincipal(principal.toString())} + > + User: {shortPrincipal(principal.toString())}{" "} + + + + {rowState} + +
+ { + getSpaceTasks({ + spaceId: spacePrincipal.toString(), + unAuthAtlasSpace, + dispatch, + }); + }} + /> +
+ ); + })} +
+ )} +
+ )} + + {isUserAdmin && allSubmissions.length === 0 && ( +
+

+ Review Submissions +

+

+ No submissions yet for this subtask. +

+
+ )}
); }; -export default TwitterTask; \ No newline at end of file +export default TwitterTask; diff --git a/src/atlas_frontend/src/modals/CreateNewTaskModal.tsx b/src/atlas_frontend/src/modals/CreateNewTaskModal.tsx index 347d2f1..0b3ae43 100644 --- a/src/atlas_frontend/src/modals/CreateNewTaskModal.tsx +++ b/src/atlas_frontend/src/modals/CreateNewTaskModal.tsx @@ -1,13 +1,22 @@ import React, { useRef, useEffect, useState } from "react"; import { yupResolver } from "@hookform/resolvers/yup"; -import { useForm, useFieldArray, type SubmitHandler, type FieldErrorsImpl } from "react-hook-form"; +import { + useForm, + useFieldArray, + type SubmitHandler, + type FieldErrorsImpl, +} from "react-hook-form"; import Button from "../components/Shared/Button"; import SpaceHeader from "../components/Shared/SpaceHeader"; import * as yup from "yup"; import { formatUnits, parseUnits } from "ethers"; import { useDispatch, useSelector } from "react-redux"; import { DECIMALS } from "../canisters/ckUsdcLedger/constans"; -import { createNewTask, editTask, getSpaceTasks } from "../canisters/atlasSpace/api"; +import { + createNewTask, + editTask, + getSpaceTasks, +} from "../canisters/atlasSpace/api"; import { useLocation, useNavigate, useParams } from "react-router-dom"; import { useAuthAtlasSpaceActor, @@ -38,7 +47,11 @@ import { FaCalendar } from "react-icons/fa"; import NumericInputForm from "../components/Shared/NumericInputForm"; import DecimalInputForm from "../components/Shared/DecimalInputForm"; import { runWithLoading } from "../utils/loading"; -import { toLocalISOString, formatDateShortMonth, formatDateShortHour } from "../utils/date"; +import { + toLocalISOString, + formatDateShortMonth, + formatDateShortHour, +} from "../utils/date"; import type { Task } from "../../../declarations/atlas_space/atlas_space.did"; import { mapTaskToForm } from "../utils/taskFormMapper"; import type { AnswerFormat } from "../../../declarations/atlas_space/atlas_space.did"; @@ -53,7 +66,8 @@ import DiscordTask from "./tasks/DiscordTask"; import TwitterTask from "./tasks/TwitterTask"; import { mapTasks, TaskType, type TaskInput } from "../utils/taskMapper"; -export const getAnswerFormatKey = (format: AnswerFormat): string => Object.keys(format)[0]; +export const getAnswerFormatKey = (format: AnswerFormat): string => + Object.keys(format)[0]; const answerFormatDescriptions: Record = { Small: "Up to 254 characters", Paragraph: "Up to 600 characters", @@ -67,15 +81,7 @@ interface CreateNewTaskFormInput { taskTitle: string; startTime: string; endTime: string; - tasks: ({ - taskType: TaskType; - title: string; - description: string; - guildId?: string; - inviteLink?: string; - allowResubmit: boolean; - answerFormat?: keyof typeof answerFormatDescriptions; - } | { disabled: boolean })[]; + tasks: (TaskInput | { disabled: boolean })[]; } type GenericTaskError = { @@ -84,7 +90,6 @@ type GenericTaskError = { type TaskForm = CreateNewTaskFormInput["tasks"][number]; type GenericTaskForm = Extract; - const maxSubtitleLength = 50; const maxTitleLength = 50; const maxDescriptionLength = 500; @@ -98,50 +103,6 @@ const answerFormatKeys = Object.keys(answerFormatDescriptions) as Array< keyof typeof answerFormatDescriptions >; -const taskSchema = yup.object({ - taskType: yup.mixed().oneOf(Object.values(TaskType)).required(), - title: yup - .string() - .trim() - .max(maxSubtitleLength) - .required() - .label("Task title"), - description: yup - .string() - .max(maxDescriptionLength) - .trim() - .min(2) - .required() - .label("Task description"), - guildId: yup - .string() - .when("taskType", { - is: (value: TaskType) => value === TaskType.Discord, - then: (schema: yup.StringSchema) => - schema - .typeError("Guild ID must be a valid string") - .required("Guild ID is required for Discord tasks"), - }) - .label("Guild ID"), - inviteLink: yup - .string() - .when("taskType", { - is: (value: TaskType) => value === TaskType.Discord, - then: (schema: yup.StringSchema) => - schema - .trim() - .matches(/^(https?:\/\/)?(www\.)?discord\.(gg|com\/invite)\/[a-zA-Z0-9-]+$/, "Invalid invite link format") - .required("Invite link is required for Discord tasks"), - }) - .label("Invite Link"), - allowResubmit: yup.boolean().required(), - answerFormat: yup - .string() - .oneOf(answerFormatKeys as string[]) - .required() - .label("Answer format"), -}); - const genericTaskSchema = yup.object({ taskType: yup.mixed().oneOf([TaskType.Generic]).required(), title: yup.string().trim().max(maxSubtitleLength).required(), @@ -154,10 +115,6 @@ const twitterTaskSchema = yup.object({ taskType: yup.mixed().oneOf([TaskType.Twitter]).required(), title: yup.string().trim().max(maxSubtitleLength).required(), description: yup.string().trim().max(maxDescriptionLength).required(), - tweetUrl: yup - .string() - .url("Must be a valid Twitter post URL") - .required("Twitter post URL is required"), allowResubmit: yup.boolean().required(), }); @@ -165,9 +122,7 @@ const discordTaskSchema = yup.object({ taskType: yup.mixed().oneOf([TaskType.Discord]).required(), title: yup.string().trim().max(maxSubtitleLength).required(), description: yup.string().trim().max(maxDescriptionLength).required(), - guildId: yup - .string() - .required("Guild ID is required for Discord tasks"), + guildId: yup.string().required("Guild ID is required for Discord tasks"), inviteLink: yup .string() .matches( @@ -189,6 +144,7 @@ const taskOrDisabledSchema = yup.lazy((value) => { case TaskType.Discord: return discordTaskSchema; case TaskType.Generic: + return genericTaskSchema; default: return genericTaskSchema; } @@ -202,9 +158,11 @@ const CreateNewTaskModal = () => { const { spacePrincipal, taskId } = useParams(); const navigate = useNavigate(); const { pathname } = useLocation(); - const title = pathname.endsWith("/edit") ? "Edit mission" : "Create new mission"; + const title = pathname.endsWith("/edit") + ? "Edit mission" + : "Create new mission"; const [isInviteValid, setInviteValid] = useState(false); - + const principal = useSpaceId({ spacePrincipal, navigate, @@ -213,18 +171,20 @@ const CreateNewTaskModal = () => { if (!principal) return <>; const agent = useUnAuthAgent(); const spaceId = principal.toString(); - + const space = useSelector((state: RootState) => { - const serializedSpace = state.spaces?.spaces?.[principal.toString()] ?? null; + const serializedSpace = + state.spaces?.spaces?.[principal.toString()] ?? null; return deserialize(serializedSpace); }); - const taskToEdit: EditableTask | null = taskId && space?.tasks?.[taskId] && "timer_id" in space.tasks[taskId] - ? { - ...space.tasks[taskId], - task_id: BigInt(taskId), - } - : null; + const taskToEdit: EditableTask | null = + taskId && space?.tasks?.[taskId] && "timer_id" in space.tasks[taskId] + ? { + ...space.tasks[taskId], + task_id: BigInt(taskId), + } + : null; const renderedAt = new Date(); const schema = yup.object({ @@ -232,7 +192,7 @@ const CreateNewTaskModal = () => { .string() .trim() .max(maxTitleLength) - .required() + .required("Task title is required") .label("Task title"), numberOfUses: yup .number() @@ -258,7 +218,7 @@ const CreateNewTaskModal = () => { if (taskToEdit) return true; return new Date(value).getTime() >= Date.now(); } - ), + ), endTime: yup .string() .required() @@ -274,11 +234,7 @@ const CreateNewTaskModal = () => { .test("is-after-now", "End time must be in the future", function (value) { return new Date(value).getTime() > Date.now(); }), - tasks: yup - .array() - .of(taskOrDisabledSchema) - .min(1) - .required(), + tasks: yup.array().of(taskOrDisabledSchema).min(1).required(), }); const dispatch = useDispatch(); @@ -292,23 +248,23 @@ const CreateNewTaskModal = () => { } = useForm({ resolver: yupResolver(schema), defaultValues: taskToEdit - ? mapTaskToForm(taskToEdit) - : { - numberOfUses: 1, - rewardPerUsage: 0.1, - taskTitle: "", - startTime: toLocalISOString(renderedAt).slice(0, 16), - endTime: '', - tasks: [ - { - taskType: TaskType.Generic, - title: "", - description: "", - allowResubmit: false, - answerFormat: "Small", + ? mapTaskToForm(taskToEdit) + : { + numberOfUses: 1, + rewardPerUsage: 0.1, + taskTitle: "", + startTime: toLocalISOString(renderedAt).slice(0, 16), + endTime: "", + tasks: [ + { + taskType: TaskType.Generic, + title: "", + description: "", + allowResubmit: false, + answerFormat: "Small", + }, + ], }, - ], - }, }); const { fields, append } = useFieldArray({ @@ -326,12 +282,12 @@ const CreateNewTaskModal = () => { const avatarImg = space?.state?.space_logo; const spaceName = space?.state?.space_name; const spaceDescription = space?.state?.space_description; - const spaceBackground = space?.state?.space_background + const spaceBackground = space?.state?.space_background; const spaceData = space?.state; useEffect(() => { if (!agent || spaceData) return; - + const loadSpaceData = async () => { const unAuthAtlasSpace = getUnAuthAtlasSpaceActor(agent, principal); await getAtlasSpace({ @@ -348,23 +304,27 @@ const CreateNewTaskModal = () => { const unAuthCkUsdcActor = useUnAuthCkUsdcLedgerActor(); const authCkUsdcActor = useAuthCkUsdcLedgerActor(); const parsedSpacePrincipal = useSpaceId({ - spacePrincipal, - navigate, - }); + spacePrincipal, + navigate, + }); if (!parsedSpacePrincipal) return <>; const blockchainConfig = deserialize( useSelector(selectBlockchainConfig) ); - const calculateDepositAmount = (amount: bigint, fee: bigint, numberOfUses: bigint) => { + const calculateDepositAmount = ( + amount: bigint, + fee: bigint, + numberOfUses: bigint + ) => { return amount * numberOfUses + fee * numberOfUses + fee; }; const ckUsdcFee = blockchainConfig - ? (blockchainConfig.ckusdc_ledger.fee ?? 0n) + ? blockchainConfig.ckusdc_ledger.fee ?? 0n : 0n; - + const numberOfUses = watch("numberOfUses"); // eslint-disable-next-line @typescript-eslint/no-explicit-any const rewardPerUsage = watch("rewardPerUsage") as any; @@ -378,14 +338,20 @@ const CreateNewTaskModal = () => { DECIMALS ); const numberOfUsesBn = BigInt(numberOfUsesNormalized); - const estimatedCost = calculateDepositAmount(rewardPerUsageBn, ckUsdcFee, numberOfUsesBn); + const estimatedCost = calculateDepositAmount( + rewardPerUsageBn, + ckUsdcFee, + numberOfUsesBn + ); - const hasAnySubmissions = !!taskToEdit && taskToEdit.tasks.some(t => { - if ("GenericTask" in t) { - return t.GenericTask.submission.length > 0; - } - return false; - }); + const hasAnySubmissions = + !!taskToEdit && + taskToEdit.tasks.some((t) => { + if ("GenericTask" in t) { + return t.GenericTask.submission.length > 0; + } + return false; + }); const onSubmit: SubmitHandler = async ({ numberOfUses, @@ -412,36 +378,8 @@ const CreateNewTaskModal = () => { return; } - // const toAnswerFormat = (key: string): AnswerFormat => { - // switch (key) { - // case "Small": - // return { Small: null }; - // case "Paragraph": - // return { Paragraph: null }; - // case "Long": - // return { Long: null }; - // case "List": - // return { List: null }; - // default: - // throw new Error(`Unknown AnswerFormat key: ${key}`); - // } - // }; - - // const taskContent = tasks.map((task) => { - // if ("disabled" in task) { - // return null; - // } - // return { - // task_type: "generic", - // title: task.title, - // description: task.description, - // allow_resubmit: task.allowResubmit, - // answer_format: toAnswerFormat(task.answerFormat), - // }; - // }); const taskContent = mapTasks( - (tasks ?? []) - .filter((t): t is TaskInput => "taskType" in t) + (tasks ?? []).filter((t): t is TaskInput => "taskType" in t) ); if (!taskContent || taskContent.length === 0) { @@ -458,11 +396,16 @@ const CreateNewTaskModal = () => { number_of_uses: taskToEdit.number_of_uses.toString(), token_reward: taskToEdit.token_reward.CkUsdc.amount.toString(), tasks: taskToEdit.tasks - .map(t => { + .map((t) => { if ("GenericTask" in t) { - return { - task_content: t.GenericTask.task_content.TitleAndDescription, - }; + const content = t.GenericTask.task_content; + if ("TitleAndDescription" in content) { + return { + task_content: content.TitleAndDescription, + }; + } + console.warn("Unexpected task_content format:", content); + return null; } return null; }) @@ -475,23 +418,57 @@ const CreateNewTaskModal = () => { end_time: endTimeUnixSec.toString(), number_of_uses: numberOfUsesBn.toString(), token_reward: rewardPerUsageBn.toString(), - tasks: taskContent.map(task => - task - ? { - task_content: { - task_description: task.description, - task_title: task.title, - allow_resubmit: task.allow_resubmit, - answer_format: task.answer_format, - }, - } - : null - ), + tasks: taskContent + .map((task) => { + if (!task) return null; + + switch (task.task_type) { + case "generic": + return { + task_content: { + task_type: "generic", + title: task.title, + description: task.description, + allow_resubmit: task.allow_resubmit, + answer_format: task.answer_format, + }, + }; + + case "discord": + return { + task_content: { + task_type: "discord", + title: task.title, + description: task.description, + invite_link: task.invite_link, + guild_id: task.guild_id, + allow_resubmit: task.allow_resubmit, + }, + }; + + case "twitter": + return { + task_content: { + task_type: "twitter", + title: task.title, + description: task.description, + allow_resubmit: task.allow_resubmit, + }, + }; + + default: + console.warn("Unknown task type:", task); + return null; + } + }) + .filter(Boolean), }; taskId = taskToEdit.task_id; - const isSameTask = JSON.stringify(sortKeys(oldTaskData)) === JSON.stringify(sortKeys(newTaskData)); + const isSameTask = + JSON.stringify(sortKeys(oldTaskData)) === + JSON.stringify(sortKeys(newTaskData)); if (isSameTask) { toast.success("No changes detected, task not updated."); navigate(getTaskPath(principal, taskId.toString())); @@ -512,7 +489,8 @@ const CreateNewTaskModal = () => { await runWithLoading(async () => { if (newDepositAndFee > currentDepositAndFee) { - const extraCost = newDepositAndFee - currentDepositAndFee + BigInt(ckUsdcFee); + const extraCost = + newDepositAndFee - currentDepositAndFee + BigInt(ckUsdcFee); const allowanceCheck = setUserSpaceAllowanceIfNeeded({ unAuthCkUsd: unAuthCkUsdcActor, authCkUsdc: authCkUsdcActor, @@ -532,29 +510,76 @@ const CreateNewTaskModal = () => { args: { task_id: taskId, task_title: taskTitle !== taskToEdit.task_title ? [taskTitle] : [], - token_reward: rewardPerUsageBn !== taskToEdit.token_reward.CkUsdc.amount ? [{ CkUsdc: { amount: rewardPerUsageBn } }]: [], - start_time: startTimeUnixSec !== Number(taskToEdit.start_time) ? [BigInt(startTimeUnixSec)] : [], - end_time: endTimeUnixSec !== Number(taskToEdit.end_time) ? [BigInt(endTimeUnixSec)] : [], - number_of_uses: numberOfUsesBn !== taskToEdit.number_of_uses ? [numberOfUsesBn] : [], + token_reward: + rewardPerUsageBn !== taskToEdit.token_reward.CkUsdc.amount + ? [{ CkUsdc: { amount: rewardPerUsageBn } }] + : [], + start_time: + startTimeUnixSec !== Number(taskToEdit.start_time) + ? [BigInt(startTimeUnixSec)] + : [], + end_time: + endTimeUnixSec !== Number(taskToEdit.end_time) + ? [BigInt(endTimeUnixSec)] + : [], + number_of_uses: + numberOfUsesBn !== taskToEdit.number_of_uses + ? [numberOfUsesBn] + : [], task_content: [ - taskContent.map(task => task - ? [{ TitleAndDescription: { - task_title: task.title, - task_description: task.description, - allow_resubmit: task.allow_resubmit, - answer_format: task.answer_format, - }}] - : [] - ) + taskContent.map((task) => { + if (!task) return []; + + switch (task.task_type) { + case "generic": + return [ + { + TitleAndDescription: { + task_title: task.title, + task_description: task.description, + allow_resubmit: task.allow_resubmit, + answer_format: task.answer_format, + }, + }, + ]; + + case "discord": + return [ + { + DiscordTask: { + task_title: task.title, + task_description: task.description, + invite_link: task.invite_link, + guild_id: task.guild_id, + allow_resubmit: task.allow_resubmit, + }, + }, + ]; + + case "twitter": + return [ + { + TwitterTask: { + task_title: task.title, + task_description: task.description, + allow_resubmit: task.allow_resubmit, + }, + }, + ]; + + default: + console.warn("Unknown task type:", task); + return []; + } + }), ], - } + }, }); await toast.promise(editedCall, { loading: "Saving changes...", success: "Task updated successfully.", error: getErrorWithInfoToast("Failed to update task:"), }); - await getSpaceTasks({ spaceId, @@ -570,7 +595,11 @@ const CreateNewTaskModal = () => { }, dispatch); } else { await runWithLoading(async () => { - const estimatedCost = calculateDepositAmount(rewardPerUsageBn, ckUsdcFee, numberOfUsesBn); + const estimatedCost = calculateDepositAmount( + rewardPerUsageBn, + ckUsdcFee, + numberOfUsesBn + ); const getOrSetAllowance = setUserSpaceAllowanceIfNeeded({ unAuthCkUsd: unAuthCkUsdcActor, authCkUsdc: authCkUsdcActor, @@ -598,7 +627,7 @@ const CreateNewTaskModal = () => { success: "Task created successfully.", error: getErrorWithInfoToast("Failed to create task:"), }); - + await getSpaceTasks({ spaceId, unAuthAtlasSpace: authAtlasSpaceActor, @@ -615,13 +644,13 @@ const CreateNewTaskModal = () => { }; const formatDisplayDateTime = (dateTimeString: string | null | undefined) => { - if (!dateTimeString) return { date: 'N/A', time: 'N/A' }; + if (!dateTimeString) return { date: "N/A", time: "N/A" }; const date = new Date(dateTimeString); - if (isNaN(date.getTime())) return { date: 'N/A', time: 'N/A' }; - + if (isNaN(date.getTime())) return { date: "N/A", time: "N/A" }; + return { date: formatDateShortMonth(date), - time: formatDateShortHour(date) + time: formatDateShortHour(date), }; }; @@ -642,335 +671,389 @@ const CreateNewTaskModal = () => { endTimeInputRef.current?.showPicker(); }; - const { - ref: startTimeRegisterRef, - ...startTimeRest - } = register("startTime"); + const { ref: startTimeRegisterRef, ...startTimeRest } = register("startTime"); - const { - ref: endTimeRegisterRef, - ...endTimeRest - } = register("endTime"); + const { ref: endTimeRegisterRef, ...endTimeRest } = register("endTime"); const resize = (el: HTMLTextAreaElement) => { el.style.height = "auto"; el.style.height = `${el.scrollHeight}px`; }; + const handleTaskTypeChange = (index: number, type: TaskType) => { + const tasks = [...(watch("tasks") ?? [])]; + tasks[index] = { ...tasks[index], taskType: type }; + setValue("tasks", tasks); + }; + return ( -
-
- -
-
-
- - -
-
- -
-

{title}

-
-
-
-
- } - formatted={formattedStartTime} - onClick={handleStartTimeClick} - inputProps={startTimeRest} - ref={(el) => { - startTimeRegisterRef(el); - startTimeInputRef.current = el; - }} - errorMessage={errors?.startTime?.message?.toString()} - className="w-full" - /> - { - endTimeRegisterRef(el); - endTimeInputRef.current = el; - }} - errorMessage={errors?.endTime?.message?.toString()} - className="w-full" - /> -
+
+
+ +
+
+
+ +
-
-
-
-

Mission Title:

- -
- {errors.taskTitle && ( -

{errors.taskTitle.message?.toString()}

- )} -
- -
-
- -
+
+ + { + console.log("Yup validation errors:", errors); + toast.error("Form validation failed — check console"); + })} + > +

+ {title} +

+
- -
- Estimated cost: {formatUnits(estimatedCost, DECIMALS)} - -
-
-
-
- {fields.length > 0 && ( - <> - {fields.map((field, index) => { - const currentTask = watch(`tasks.${index}`); - if ("disabled" in currentTask) { - return ( -
-

- Task {index + 1} (deleted) -

-
- ); - } - return ( -
-

- Task {index + 1} -

-
- - +
+ } + formatted={formattedStartTime} + onClick={handleStartTimeClick} + inputProps={startTimeRest} + ref={(el) => { + startTimeRegisterRef(el); + startTimeInputRef.current = el; + }} + errorMessage={errors?.startTime?.message?.toString()} + className="w-full" /> - {(errors?.tasks?.[index] as FieldErrorsImpl)?.title && ( -

- { - (errors.tasks?.[index] as FieldErrorsImpl) - ?.title?.message?.toString() - } -

- )} -
-
- - - {descriptionError && ( - {descriptionError} - )} -

Guild ID:

- { - setSelectedGuild(guild); - onChange({ - target: { name: `tasks.${index}.guildId`, value: guild.id }, - }); - }} +return ( + <> +
+ + )} + className={`w-full p-3 rounded-lg bg-primary/20 text-white placeholder-gray-300 focus:outline-none focus:ring-2 focus:ring-white/50 ${ + titleError ? "ring-2 ring-red-500" : "" + }`} + placeholder="Enter task title" /> - {guildIdError && ( - {guildIdError} + {titleError && ( +

+ {titleError.toString()} +

)} - {!accessToken && ( -
+ +
+ + - {descriptionError && ( - {descriptionError} - )} -
+
+ +