Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions Cargo.lock

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

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ resolver = "2"
[workspace.dependencies]
candid = "0.10"
ic-cdk = "0.18.5"
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"] }
Expand Down
60 changes: 51 additions & 9 deletions src/atlas_frontend/src/canisters/atlasSpace/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,29 @@ import type { Dispatch } from "react";
import type { UnknownAction } from "@reduxjs/toolkit";
import type { Principal } from "@dfinity/principal";
import type { ExternalLinks } from "./types.js";
import { getUserGuilds } from "../../components/Integrations/discord/userGuilds.js";
import { validateDiscordInvite as validateInvite } from "../../components/Integrations/discord/inviteLink.js";
import type { DiscordGuild, DiscordInviteApiResponse } from "../../components/Integrations/discord/types.js";
import { string } from "yup";

interface CreateSubtaskArg {
task_type: string;
interface GenericSubtaskArg {
task_type: "generic";
title: string;
description: string;
allow_resubmit: boolean;
}

interface DiscordSubtaskArg {
task_type: "discord";
title: string;
description: string;
allow_resubmit: boolean;
guild_id: string;
invite_link: string;
}
Comment thread
Nyaxize marked this conversation as resolved.
Outdated

type CreateSubtaskArg = GenericSubtaskArg | DiscordSubtaskArg;

interface GetAtlasSpaceArgs {
unAuthAtlasSpace: ActorSubclass<_SERVICE>;
spaceId: string;
Expand Down Expand Up @@ -83,13 +98,27 @@ export const createNewTask = async ({
tasks,
taskTitle,
}: CreateNewSpaceTaskArgs) => {
const transformedTasks: TaskContent[] = tasks.map((arg) => ({
TitleAndDescription: {
task_title: arg.title,
task_description: arg.description,
allow_resubmit: arg.allow_resubmit,
},
}));
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 {
return {
TitleAndDescription: {
task_title: arg.title,
task_description: arg.description,
allow_resubmit: arg.allow_resubmit,
},
};
}
});

const call = authAtlasSpaceActor.create_task({
task_title: taskTitle,
Expand Down Expand Up @@ -301,3 +330,16 @@ export const editSpace = async ({
errMsg: "Failed to edit space",
});
};

export const getDiscordGuilds = async (
accessToken: string
): Promise<DiscordGuild[]> => {
return await getUserGuilds(accessToken);
};

export const validateDiscordInvite = async (
inviteCode: string,
expectedGuildId: string
): Promise<DiscordInviteApiResponse> => {
return await validateInvite(inviteCode, expectedGuildId);
};
39 changes: 19 additions & 20 deletions src/atlas_frontend/src/canisters/atlasSpace/tasks.ts
Original file line number Diff line number Diff line change
@@ -1,32 +1,31 @@
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);

return new UserSubmissions(data);
};
}

export class UserSubmissions {
constructor(public userSubmissionsData: UserSubmissionsData) {}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,21 @@
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
}

try {
window.opener.postMessage(
{ tokenType, accessToken, state, expiresIn },
{accessToken},
window.location.origin
);
} catch (err) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { DiscordInviteApiResponse } from "./types";

export const validateDiscordInvite = async (
inviteCode: string,
expectedGuildId: string
): Promise<DiscordInviteApiResponse> => {
if (!inviteCode || inviteCode.includes("/")) {
throw new Error("Invalid Discord invite code format.");
}

const url = `https://discord.com/api/v10/invites/${inviteCode}?with_counts=false`;

const response = await fetch(url);
Comment thread
Nyaxize marked this conversation as resolved.
Outdated

if (!response.ok) {
const errorText = await response.text();
throw new Error(
`Discord API returned status ${response.status}: ${errorText}`
);
}

const inviteData: DiscordInviteApiResponse = await response.json();

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.");
}
};
17 changes: 17 additions & 0 deletions src/atlas_frontend/src/components/Integrations/discord/types.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { DiscordGuild } from "./types";

export const getUserGuilds = async (token: string): Promise<DiscordGuild[]> => {
const url = "https://discord.com/api/users/@me/guilds";

const response = await fetch(url, {
headers: {
Authorization: `Bearer ${token.trim()}`,
"Content-Type": "application/json",
},
});

if (!response.ok) {
throw new Error(`Failed to fetch user guilds: ${response.statusText}`);
}

const guilds: DiscordGuild[] = await response.json();
return guilds;
};
17 changes: 10 additions & 7 deletions src/atlas_frontend/src/components/Shared/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,20 @@ interface ButtonProps {
arrow?: boolean;
smallText?: string
light?: boolean
disabled?: boolean;
}

const Button = ({children, onClick, className, light}: ButtonProps) => {
const Button = ({children, onClick, className, light, disabled}: ButtonProps) => {
return (
<motion.button whileHover={{ scale: 1.01 }} whileTap={{ scale: 0.99 }} onClick={onClick}
className={`cursor-pointer flex justify-center rounded-xl px-2 py-2 items-center md:text-base font-medium bg-[#9173FF] md:px-6 md:py-2 md:rounded-2xl text-white ${light ? "bg-[#9173FF]/20" : "bg-[#9173FF]"} ${className ?? ""}`}>


<motion.div whileHover={!disabled ? { scale: 1.01 } : {}} whileTap={!disabled ? { scale: 0.99 } : {}}>
<button
onClick={onClick}
disabled={disabled}
className={`cursor-pointer flex justify-center items-center font-medium bg-[#9173FF] px-6 py-2 rounded-xl text-white ${light ? "bg-[#9173FF]/20" : "bg-[#9173FF]"} ${className ?? ""} disabled:opacity-50 disabled:cursor-not-allowed`}
>
{children}

</motion.button>
</button>
</motion.div>
);
};

Expand Down
29 changes: 24 additions & 5 deletions src/atlas_frontend/src/components/Space/TaskCard/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,30 @@ interface TaskCardProps {
spaceId: Principal
}

const TaskCard = ({ startingIn, task, id, type, spaceId}: TaskCardProps) => {
const getAcceptedSubmissions = (task: Task) => {
const last = task.tasks.at(-1);
if (!last) {
return 0;
}
const submissions =
"GenericTask" in last
? last.GenericTask.submission
: "DiscordTask" in last
? last.DiscordTask.submission
: [];
Comment thread
Nyaxize marked this conversation as resolved.
Outdated
Comment thread
Nyaxize marked this conversation as resolved.
Outdated
return submissions.filter(([, submission]) => "Accepted" in submission.state)
.length;
};

const TaskCard = ({ startingIn, task, id, type, spaceId }: TaskCardProps) => {
const navigate = useNavigate();

const reward = formatUnits(task.token_reward.CkUsdc.amount, DECIMALS)
const lastTask = task.tasks.at(-1)?.GenericTask.submission.filter(([, submission]) => 'Accepted' in submission.state)
const reward = formatUnits(task.token_reward.CkUsdc.amount, DECIMALS);

const acceptedCount = getAcceptedSubmissions(task);

return (
<div className="w-full md:w-[20rem]">
<div className="w-[20rem]">
<a className="rounded-xl bg-gradient-to-b from-[#9173FF] to-transparent to-[150%] flex flex-col" onClick={() => navigate(getTaskPath(spaceId, id))}>
<div
className={`h-40 p-4 rounded-t-xl ${
Expand All @@ -42,7 +58,9 @@ const TaskCard = ({ startingIn, task, id, type, spaceId}: TaskCardProps) => {
/>
<InfoBox type="steps" steps={task.tasks.length} />
{/* //TODO: fix count of submission */}
<InfoBox type="uses" uses={`${lastTask?.length}/${task.number_of_uses}`} />
<InfoBox
type="uses"
uses={`${acceptedCount}/${task.number_of_uses}`} />
</div>
</div>
</a>
Expand All @@ -51,3 +69,4 @@ const TaskCard = ({ startingIn, task, id, type, spaceId}: TaskCardProps) => {
};

export default TaskCard;

Loading