diff --git a/.all-contributorsrc b/.all-contributorsrc index d9607ec5..3e1a42a2 100644 --- a/.all-contributorsrc +++ b/.all-contributorsrc @@ -108,6 +108,15 @@ "contributions": [ "code" ] + }, + { + "login": "PoulavBhowmick03", + "name": "Poulav Bhowmick", + "avatar_url": "https://avatars.githubusercontent.com/u/133862694?v=4", + "profile": "https://poulav.vercel.app/", + "contributions": [ + "code" + ] } ], "contributorsPerLine": 7, diff --git a/.gitmodules b/.gitmodules index 13973c8a..579ec75c 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,9 +1,15 @@ -[submodule "onchain/lib/kakarot-rpc"] - path = onchain/lib/kakarot-rpc - url = https://github.com/kkrt-labs/kakarot-rpc -[submodule "onchain/solidity_contracts/lib/forge-std"] +[submodule "onchain/lib/kakarot-rpc"] + path = onchain/lib/kakarot-rpc + url = https://github.com/kkrt-labs/kakarot-rpc +[submodule "onchain/solidity_contracts/lib/forge-std"] path = onchain/solidity_contracts/lib/forge-std url = https://github.com/foundry-rs/forge-std -[submodule "onchain/solidity_contracts/lib/kakarot-lib"] - path = onchain/solidity_contracts/lib/kakarot-lib - url = https://github.com/kkrt-labs/kakarot-lib +[submodule "onchain/solidity_contracts/lib/kakarot-lib"] + path = onchain/solidity_contracts/lib/kakarot-lib + url = https://github.com/kkrt-labs/kakarot-lib +[submodule "onchain/solidity_contracts/lib/openzeppelin-contracts-upgradeable"] + path = onchain/solidity_contracts/lib/openzeppelin-contracts-upgradeable + url = https://github.com/OpenZeppelin/openzeppelin-contracts-upgradeable +[submodule "onchain/solidity_contracts/lib/openzeppelin-contracts"] + path = onchain/solidity_contracts/lib/openzeppelin-contracts + url = https://github.com/OpenZeppelin/openzeppelin-contracts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..cbac5697 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "deno.enable": true +} diff --git a/README.md b/README.md index 35b8d931..36d9ddf9 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d Yusuf Habib
Yusuf Habib

💻 Isaac Onyemaechi Ugwu
Isaac Onyemaechi Ugwu

💻 Oche
Oche

💻 + Poulav Bhowmick
Poulav Bhowmick

💻 diff --git a/apps/data-backend/src/services/telegram-app.ts b/apps/data-backend/src/services/telegram-app.ts index a505f35f..056d8e3f 100644 --- a/apps/data-backend/src/services/telegram-app.ts +++ b/apps/data-backend/src/services/telegram-app.ts @@ -33,7 +33,7 @@ export function launchBot(token:string) { // Handle stop events enableGracefulStop(bot) - return bot + // return bot } catch (e) { console.log("launchBot error", e) } diff --git a/apps/indexer/.env.example b/apps/indexer/.env.example new file mode 100644 index 00000000..ebacfa1b --- /dev/null +++ b/apps/indexer/.env.example @@ -0,0 +1,2 @@ +AUTH_TOKEN=dna_xxx +POSTGRES_CONNECTION_STRING=postgresql://admin:postgres@localhost:5432/indexer \ No newline at end of file diff --git a/apps/indexer/Dockerfile b/apps/indexer/Dockerfile index e5fa3914..79e6de98 100644 --- a/apps/indexer/Dockerfile +++ b/apps/indexer/Dockerfile @@ -11,4 +11,8 @@ FROM quay.io/apibara/sink-postgres:0.7.0-x86_64 WORKDIR /app COPY ./src/* /app +ARG POSTGRES_CONNECTION_STRING + +ENV POSTGRES_CONNECTION_STRING=${POSTGRES_CONNECTION_STRING} + ENTRYPOINT ["/nix/store/rh1g8pb7wfnyr527jfmkkc5lm3sa1f0l-apibara-sink-postgres-0.7.0/bin/apibara-sink-postgres"] diff --git a/apps/indexer/README.md b/apps/indexer/README.md index 0f159b8a..ba2dbc4e 100644 --- a/apps/indexer/README.md +++ b/apps/indexer/README.md @@ -5,16 +5,15 @@ ## Install Postgres and Init the tables ``` -docker run --name afk-indexer -e POSTGRES_PASSWORD=postgres -d -p 5432:5432 -v /afk-indexer:/docker-entrypoint-initdb.d postgres:16 +docker run --name afk-indexer -e POSTGRES_PASSWORD=postgres -e POSTGRES_USER=admin -e POSTGRES_DB=indexer -d -p 5432:5432 -v ./:/docker-entrypoint-initdb.d postgres:latest ``` # Test -unrugmeme_deploy +buy-token deploy ``` -apibara run ./src/pump-buy-coin.js -A dna_XXX - +apibara run ./src/buy-token.ts --allow-env .env -A dna_xxx ``` @@ -27,4 +26,4 @@ apibara run ./src/pump-buy-coin.js -A dna_XXX ``` ### Run it - docker run -it --env-file ./.env afk-indexer run /app/pump-deploy-coin.js --tls-accept-invalid-certificates=true --connection-string POSTGRES:INDEXER_DATABASE_URL \ No newline at end of file + docker run -it --env-file ./.env afk-indexer run /app/buy-token.ts --tls-accept-invalid-certificates=true --allow-env-from-env POSTGRES_CONNECTION_STRING \ No newline at end of file diff --git a/apps/indexer/docker-compose.yml b/apps/indexer/docker-compose.yml index 6542bf90..3c5137dc 100644 --- a/apps/indexer/docker-compose.yml +++ b/apps/indexer/docker-compose.yml @@ -6,7 +6,7 @@ services: environment: POSTGRES_DB: indexer POSTGRES_USER: admin - POSTGRES_PASSWORD: password + POSTGRES_PASSWORD: postgres ports: - '5432:5432' volumes: @@ -17,8 +17,9 @@ services: unruggableMemecoin-deploy-indexer: environment: - AUTH_TOKEN=${AUTH_TOKEN} + - POSTGRES_CONNECTION_STRING=postgresql://admin:postgres@postgres:5432/indexer image: quay.io/apibara/sink-postgres:latest - command: 'run ./indexer/unruggableMemecoin-deploy.indexer.ts --connection-string postgresql://admin:password@postgres:5432/indexer -A ${AUTH_TOKEN}' + command: 'run ./indexer/unruggableMemecoin-deploy.indexer.ts --allow-env-from-env AUTH_TOKEN,POSTGRES_CONNECTION_STRING -A ${AUTH_TOKEN}' volumes: - ./src:/indexer depends_on: @@ -30,8 +31,9 @@ services: unruggableMemecoin-launch-indexer: environment: - AUTH_TOKEN=${AUTH_TOKEN} + - POSTGRES_CONNECTION_STRING=postgresql://admin:postgres@postgres:5432/indexer image: quay.io/apibara/sink-postgres:latest - command: 'run ./indexer/unruggableMemecoin-launch.indexer.ts --connection-string postgresql://admin:password@postgres:5432/indexer -A ${AUTH_TOKEN}' + command: 'run ./indexer/unruggableMemecoin-launch.indexer.ts --allow-env-from-env AUTH_TOKEN,POSTGRES_CONNECTION_STRING -A ${AUTH_TOKEN}' volumes: - ./src:/indexer depends_on: @@ -43,8 +45,9 @@ services: unruggableMemecoin-transfers-indexer: environment: - AUTH_TOKEN=${AUTH_TOKEN} + - POSTGRES_CONNECTION_STRING=postgresql://admin:postgres@postgres:5432/indexer image: quay.io/apibara/sink-postgres:latest - command: 'run ./indexer/unruggableMemecoin-transfers.indexer.ts --connection-string postgresql://admin:password@postgres:5432/indexer -A ${AUTH_TOKEN}' + command: 'run ./indexer/unruggableMemecoin-transfers.indexer.ts --allow-env-from-env AUTH_TOKEN,POSTGRES_CONNECTION_STRING -A ${AUTH_TOKEN}' volumes: - ./src:/indexer depends_on: @@ -53,6 +56,65 @@ services: - backend restart: on-failure + buy-indexer: + environment: + - AUTH_TOKEN=${AUTH_TOKEN} + - POSTGRES_CONNECTION_STRING=postgresql://admin:postgres@postgres:5432/indexer + image: quay.io/apibara/sink-postgres:latest + command: 'run ./indexer/buy-token.ts --allow-env-from-env AUTH_TOKEN,POSTGRES_CONNECTION_STRING -A ${AUTH_TOKEN}' + volumes: + - ./src:/indexer + depends_on: + - postgres + networks: + - backend + restart: on-failure + + + sell-indexer: + environment: + - AUTH_TOKEN=${AUTH_TOKEN} + - POSTGRES_CONNECTION_STRING=postgresql://admin:postgres@postgres:5432/indexer + image: quay.io/apibara/sink-postgres:latest + command: 'run ./indexer/sell-token.ts --allow-env-from-env AUTH_TOKEN,POSTGRES_CONNECTION_STRING -A ${AUTH_TOKEN}' + volumes: + - ./src:/indexer + depends_on: + - postgres + networks: + - backend + restart: on-failure + + token-indexer: + environment: + - AUTH_TOKEN=${AUTH_TOKEN} + - POSTGRES_CONNECTION_STRING=postgresql://admin:postgres@postgres:5432/indexer + image: quay.io/apibara/sink-postgres:latest + command: 'run ./indexer/token-launch.ts --allow-env-from-env AUTH_TOKEN,POSTGRES_CONNECTION_STRING -A ${AUTH_TOKEN}' + volumes: + - ./src:/indexer + depends_on: + - postgres + networks: + - backend + restart: on-failure + + deploy-indexer: + environment: + - AUTH_TOKEN=${AUTH_TOKEN} + - POSTGRES_CONNECTION_STRING=postgresql://admin:postgres@postgres:5432/indexer + image: quay.io/apibara/sink-postgres:latest + command: 'run ./indexer/deploy-token.ts --allow-env-from-env AUTH_TOKEN,POSTGRES_CONNECTION_STRING -A ${AUTH_TOKEN}' + volumes: + - ./src:/indexer + depends_on: + - postgres + networks: + - backend + restart: on-failure + + + networks: backend: driver: bridge diff --git a/apps/indexer/init.sql b/apps/indexer/init.sql index 1344bb05..feacd7d7 100644 --- a/apps/indexer/init.sql +++ b/apps/indexer/init.sql @@ -12,10 +12,8 @@ create table token_launch( current_supply text, liquidity_raised text, price text, - _cursor bigint - timestamp TIMESTAMP, - - + _cursor bigint, + time_stamp TEXT ); create table token_deploy( @@ -32,8 +30,8 @@ create table token_deploy( initial_supply text, total_supply text, created_at timestamp default current_timestamp, - _cursor bigint - timestamp TIMESTAMP, + _cursor bigint, + time_stamp TEXT ); @@ -57,10 +55,11 @@ CREATE TABLE token_transactions ( current_supply TEXT, liquidity_raised TEXT, price TEXT, + protocol_fee TEXT, amount TEXT, - timestamp TIMESTAMP, - _cursor bigint, - transaction_type TEXT NOT NULL CHECK (transaction_type IN ('buy', 'sell')),, + _cursor BIGINT, + transaction_type TEXT NOT NULL CHECK (transaction_type IN ('buy', 'sell')), + time_stamp TEXT ); diff --git a/apps/indexer/src/buy-token.js b/apps/indexer/src/buy-token.js deleted file mode 100644 index df2a7af8..00000000 --- a/apps/indexer/src/buy-token.js +++ /dev/null @@ -1,67 +0,0 @@ -import { hash, uint256 } from "https://esm.run/starknet@5.14"; -import { STARTING_BLOCK, LAUNCHPAD_ADDRESS } from "./constants"; - -const filter = { - header: { - weak: true, - }, - events: [ - { - fromAddress: LAUNCHPAD_ADDRESS.SEPOLIA, - keys: [hash.getSelectorFromName('BuyToken')], - includeReceipt: false, - }, - ], -} - -export const config = { - streamUrl: 'https://sepolia.starknet.a5a.ch', - startingBlock: STARTING_BLOCK, - network: 'starknet', - finality: 'DATA_STATUS_ACCEPTED', - filter, - sinkType: 'postgres', - sinkOptions: { - connectionString: '', // Specify your PostgreSQL connection string here - tableName: 'token_transactions', - }, -} - -export default function DecodeBuyToken({ header, events }) { - const { blockNumber, blockHash, timestamp } = header; - - return (events ?? []).map(({ event, transaction }) => { - if (!event.data) return; - - const transactionHash = transaction.meta.hash; - const [ - caller, - token_address, - amount_low, amount_high, - price_low, price_high, - protocol_fee_low, protocol_fee_high, - initial_supply_low, initial_supply_high, - ] = event.data; - - const amount = uint256.uint256ToBN({ low: amount_low, high: amount_high }).toString(); - const price = uint256.uint256ToBN({ low: price_low, high: price_high }).toString(); - const protocol_fee = uint256.uint256ToBN({ low: protocol_fee_low, high: protocol_fee_high }).toString(); - const initial_supply = uint256.uint256ToBN({ low: initial_supply_low, high: initial_supply_high }).toString(); - - return { - transaction_type: 'buy', - network: 'starknet-sepolia', - block_hash: blockHash, - block_number: Number(blockNumber), - block_timestamp: timestamp, - transaction_hash: transactionHash, - memecoin_address: token_address, - owner_address: caller, - initial_supply, - price, - protocol_fee, - timestamp: new Date(timestamp * 1000).toISOString(), // UNIX timestamp to ISO string - created_at: new Date().toISOString(), - }; - }); -} \ No newline at end of file diff --git a/apps/indexer/src/buy-token.ts b/apps/indexer/src/buy-token.ts new file mode 100644 index 00000000..9de2420d --- /dev/null +++ b/apps/indexer/src/buy-token.ts @@ -0,0 +1,89 @@ +import { Block, hash, uint256 } from "./deps.ts"; +import { STARTING_BLOCK, LAUNCHPAD_ADDRESS } from "./constants.ts"; + +const filter = { + header: { + weak: true + }, + events: [ + { + fromAddress: LAUNCHPAD_ADDRESS.SEPOLIA, + keys: [hash.getSelectorFromName("BuyToken")], + includeReceipt: false + } + ] +}; + +export const config = { + streamUrl: "https://sepolia.starknet.a5a.ch", + startingBlock: STARTING_BLOCK, + network: "starknet", + finality: "DATA_STATUS_ACCEPTED", + filter, + sinkType: "postgres", + sinkOptions: { + connectionString: Deno.env.get("POSTGRES_CONNECTION_STRING"), + tableName: "token_transactions" + } +}; + +export default function DecodeBuyToken({ header, events }: Block) { + const { blockNumber, blockHash, timestamp: block_timestamp } = header!; + + return (events ?? []).map(({ event, transaction }) => { + if (!event.data) return; + + const transactionHash = transaction.meta.hash; + const transfer_id = `${transactionHash}_${event.index}`; + + const [caller, token_address] = event.keys!; + + const [ + amount_low, + amount_high, + price_low, + price_high, + protocol_fee_low, + protocol_fee_high, + last_price_low, + last_price_high, + timestamp, + quote_amount_low, + quote_amount_high + ] = event.data; + + const amount = uint256 + .uint256ToBN({ low: amount_low, high: amount_high }) + .toString(); + const price = uint256 + .uint256ToBN({ low: price_low, high: price_high }) + .toString(); + const protocol_fee = uint256 + .uint256ToBN({ low: protocol_fee_low, high: protocol_fee_high }) + .toString(); + const last_price = uint256 + .uint256ToBN({ low: last_price_low, high: last_price_high }) + .toString(); + const quote_amount = uint256 + .uint256ToBN({ low: quote_amount_low, high: quote_amount_high }) + .toString(); + + return { + transfer_id, + network: "starknet-sepolia", + block_hash: blockHash, + block_number: Number(blockNumber), + block_timestamp: block_timestamp, + transaction_hash: transactionHash, + memecoin_address: token_address, + owner_address: caller, + last_price, + quote_amount, + price, + amount, + protocol_fee, + time_stamp: timestamp, + transaction_type: "buy" + }; + }); +} diff --git a/apps/indexer/src/constants.js b/apps/indexer/src/constants.js deleted file mode 100644 index c5ec894b..00000000 --- a/apps/indexer/src/constants.js +++ /dev/null @@ -1,7 +0,0 @@ -export const FACTORY_ADDRESS = '0x01a46467a9246f45c8c340f1f155266a26a71c07bd55d36e8d1c7d0d438a2dbc' -export const STARTING_BLOCK = 100_000 -export const LAUNCHPAD_ADDRESS = { - // SEPOLIA:"0x74acb6752abb734a7b3388567429217988e02409d9bf43c5586dc2c4f8baf40", - // SEPOLIA:"0x29a532e6933a6d6f9939e59469d96b52b7c38561745331302e1a29f035e4dd0", - SEPOLIA:"0x3798921000573bfc442d8153fc088db97bd3794f5ed19ea8c0846db5378f4af" -} diff --git a/apps/indexer/src/constants.ts b/apps/indexer/src/constants.ts new file mode 100644 index 00000000..93b2a494 --- /dev/null +++ b/apps/indexer/src/constants.ts @@ -0,0 +1,8 @@ +export const FACTORY_ADDRESS = + "0x01a46467a9246f45c8c340f1f155266a26a71c07bd55d36e8d1c7d0d438a2dbc"; +export const STARTING_BLOCK = 140_000; +export const LAUNCHPAD_ADDRESS = { + // SEPOLIA:"0x74acb6752abb734a7b3388567429217988e02409d9bf43c5586dc2c4f8baf40", + // SEPOLIA:"0x29a532e6933a6d6f9939e59469d96b52b7c38561745331302e1a29f035e4dd0", + SEPOLIA: "0x3798921000573bfc442d8153fc088db97bd3794f5ed19ea8c0846db5378f4af" +}; diff --git a/apps/indexer/src/deploy-token.js b/apps/indexer/src/deploy-token.js deleted file mode 100644 index b2e88bb0..00000000 --- a/apps/indexer/src/deploy-token.js +++ /dev/null @@ -1,70 +0,0 @@ -import { LAUNCHPAD_ADDRESS, STARTING_BLOCK } from './constants.js' -import { hash, uint256, shortString } from "https://esm.run/starknet@5.14"; - -const filter = { - header: { - weak: true, - }, - events: [ - { - fromAddress: LAUNCHPAD_ADDRESS.SEPOLIA, - keys: [hash.getSelectorFromName('CreateToken')], - includeReceipt: false, - }, - ], -} - -export const config = { - streamUrl: 'https://sepolia.starknet.a5a.ch', - startingBlock: STARTING_BLOCK, - network: 'starknet', - finality: 'DATA_STATUS_ACCEPTED', - filter, - sinkType: 'postgres', - sinkOptions: { - connectionString: '', - tableName: 'token_deploy', - }, -} - -export default function DecodeTokenDeploy({ header, events }) { - const { blockNumber, blockHash, timestamp } = header; - - return (events ?? []).map(({ event, transaction }) => { - if (!event.data || !event.keys) return; - - const transactionHash = transaction.meta.hash; - const [caller, token_address, - // name, symbol - ] = event.keys; - - const [ - initial_supply_low, initial_supply_high, - total_supply_low, total_supply_high, - symbol, name - ] = event.data; - - const initial_supply = uint256.uint256ToBN({ low: initial_supply_low, high: initial_supply_high }).toString(); - const total_supply = uint256.uint256ToBN({ low: total_supply_low, high: total_supply_high }).toString(); - const name_decoded = shortString.decodeShortString(name); - const symbol_decoded = shortString.decodeShortString(symbol); - - return { - network: 'starknet-sepolia', - block_hash: blockHash, - block_number: Number(blockNumber), - block_timestamp: timestamp, - transaction_hash: transactionHash, - memecoin_address: token_address, - owner_address: caller, - name: name_decoded, - symbol: symbol_decoded, - initial_supply, - total_supply, - created_at: new Date().toISOString(), - _cursor: transaction.meta.cursor, - timestamp: new Date(timestamp * 1000).toISOString(), - - }; - }); -} \ No newline at end of file diff --git a/apps/indexer/src/deploy-token.ts b/apps/indexer/src/deploy-token.ts new file mode 100644 index 00000000..dcf32007 --- /dev/null +++ b/apps/indexer/src/deploy-token.ts @@ -0,0 +1,91 @@ +import { LAUNCHPAD_ADDRESS, STARTING_BLOCK } from "./constants.ts"; +import { Block, hash, uint256, shortString } from "./deps.ts"; + +const filter = { + header: { + weak: true + }, + events: [ + { + fromAddress: LAUNCHPAD_ADDRESS.SEPOLIA, + keys: [hash.getSelectorFromName("CreateToken")], + includeReceipt: false + } + ] +}; + +export const config = { + streamUrl: "https://sepolia.starknet.a5a.ch", + startingBlock: STARTING_BLOCK, + network: "starknet", + finality: "DATA_STATUS_ACCEPTED", + filter, + sinkType: "postgres", + sinkOptions: { + connectionString: Deno.env.get("POSTGRES_CONNECTION_STRING"), + tableName: "token_deploy" + } +}; + +export default function DecodeTokenDeploy({ header, events }: Block) { + const { blockNumber, blockHash, timestamp } = header!; + + return (events ?? []).map(({ event, transaction }) => { + if (!event.data || !event.keys) return; + + const transactionHash = transaction.meta.hash; + const [caller, token_address] = event.keys; + + const [ + symbol, + name, + initial_supply_low, + initial_supply_high, + total_supply_low, + total_supply_high + ] = event.data; + + const symbol_decoded = token_address + ? shortString.decodeShortString(symbol.replace(/0x0+/, "0x")) + : ""; + const name_decoded = name + ? shortString.decodeShortString(name.replace(/0x0+/, "0x")) + : ""; + const initial_supply = uint256 + .uint256ToBN({ low: initial_supply_low, high: initial_supply_high }) + .toString(); + const total_supply = uint256 + .uint256ToBN({ low: total_supply_low, high: total_supply_high }) + .toString(); + + console.log({ + memecoin_address: token_address, + network: "starknet-sepolia", + block_hash: blockHash, + block_number: Number(blockNumber), + block_timestamp: timestamp, + transaction_hash: transactionHash, + owner_address: caller, + name: name_decoded, + symbol: symbol_decoded, + initial_supply, + total_supply, + time_stamp: timestamp + }); + + return { + memecoin_address: token_address, + network: "starknet-sepolia", + block_hash: blockHash, + block_number: Number(blockNumber), + block_timestamp: timestamp, + transaction_hash: transactionHash, + owner_address: caller, + name: name_decoded, + symbol: symbol_decoded, + initial_supply, + total_supply, + time_stamp: timestamp + }; + }); +} diff --git a/apps/indexer/src/deps.js b/apps/indexer/src/deps.js deleted file mode 100644 index b7b93c9e..00000000 --- a/apps/indexer/src/deps.js +++ /dev/null @@ -1,6 +0,0 @@ -export { ec, hash, uint256, shortString } from 'https://esm.sh/starknet@5.14' -export { formatUnits } from 'https://esm.sh/viem@1.4' - -export { Block, FieldElement, Filter } from 'https://esm.sh/@apibara/indexer@0.3/starknet' -// export { Config, NetworkOptions } from "https://esm.sh/@apibara/indexer"; -// export { Console } from "https://esm.sh/@apibara/indexer/sink/console"; \ No newline at end of file diff --git a/apps/indexer/src/deps.ts b/apps/indexer/src/deps.ts new file mode 100644 index 00000000..70496773 --- /dev/null +++ b/apps/indexer/src/deps.ts @@ -0,0 +1,13 @@ +export { + ec, + hash, + uint256, + shortString, + cairo +} from "https://esm.sh/starknet@5.14.1"; +export { formatUnits } from "https://esm.sh/viem@1.4.2"; +export type { + Block, + FieldElement, + Filter +} from "https://esm.sh/@apibara/indexer@0.3.1/starknet"; diff --git a/apps/indexer/src/script.js b/apps/indexer/src/script.js deleted file mode 100644 index 476627be..00000000 --- a/apps/indexer/src/script.js +++ /dev/null @@ -1,18 +0,0 @@ -export const config = { - streamUrl: "https://sepolia.starknet.a5a.ch", - startingBlock: 10_000, - network: "starknet", - finality: "DATA_STATUS_ACCEPTED", - filter: { - header: {}, - }, - // sinkType: "console", - sinkType: "postgres", - sinkOptions: {}, - }; - - // This transform does nothing. - export default function transform(block) { - return block; -} - diff --git a/apps/indexer/src/script.ts b/apps/indexer/src/script.ts new file mode 100644 index 00000000..73584065 --- /dev/null +++ b/apps/indexer/src/script.ts @@ -0,0 +1,18 @@ +import { Block } from "./deps.ts"; + +export const config = { + streamUrl: "https://sepolia.starknet.a5a.ch", + startingBlock: 10_000, + network: "starknet", + finality: "DATA_STATUS_ACCEPTED", + filter: { + header: {} + }, + sinkType: "postgres", + sinkOptions: {} +}; + +// This transform does nothing. +export default function transform(block: Block) { + return block; +} diff --git a/apps/indexer/src/sell-token.js b/apps/indexer/src/sell-token.js deleted file mode 100644 index f93ffd3c..00000000 --- a/apps/indexer/src/sell-token.js +++ /dev/null @@ -1,74 +0,0 @@ -import { hash, uint256 } from "https://esm.run/starknet@5.14"; -import { STARTING_BLOCK, LAUNCHPAD_ADDRESS } from "./constants"; - -const filter = { - header: { - weak: true, - }, - events: [ - { - fromAddress: LAUNCHPAD_ADDRESS.SEPOLIA, - keys: [hash.getSelectorFromName('SellToken')], - includeReceipt: false, - }, - ], -} - -export const config = { - streamUrl: 'https://sepolia.starknet.a5a.ch', - startingBlock: STARTING_BLOCK, - network: 'starknet', - finality: 'DATA_STATUS_ACCEPTED', - filter, - sinkType: 'postgres', - sinkOptions: { - connectionString: '', // Your PostgreSQL connection string - tableName: 'token_transactions', // Using the same table for buy and sell - }, -} - -export default function DecodeSellToken({ header, events }) { - const { blockNumber, blockHash, timestamp } = header; - - return (events ?? []).map(({ event, transaction }) => { - if (!event.data) return; - - const transactionHash = transaction.meta.hash; - const [ - seller, - token_address, - amount_low, amount_high, - price_low, price_high, - protocol_fee_low, protocol_fee_high, - total_supply_low, total_supply_high, - ] = event.data; - - const amount = uint256.uint256ToBN({ low: amount_low, high: amount_high }).toString(); - const price = uint256.uint256ToBN({ low: price_low, high: price_high }).toString(); - const protocol_fee = uint256.uint256ToBN({ low: protocol_fee_low, high: protocol_fee_high }).toString(); - const total_supply = uint256.uint256ToBN({ low: total_supply_low, high: total_supply_high }).toString(); - - return { - transaction_type: 'sell', - network: 'starknet-sepolia', - block_hash: blockHash, - block_number: Number(blockNumber), - block_timestamp: timestamp, - transaction_hash: transactionHash, - memecoin_address: token_address, - owner_address: seller, - last_price: price, - quote_amount: '', - coin_received: '', - initial_supply: '', - total_supply, - price, - amount, - timestamp: new Date(timestamp * 1000).toISOString(), - created_at: new Date().toISOString(), - }; - }); -} - - - diff --git a/apps/indexer/src/sell-token.ts b/apps/indexer/src/sell-token.ts new file mode 100644 index 00000000..7269eaab --- /dev/null +++ b/apps/indexer/src/sell-token.ts @@ -0,0 +1,89 @@ +import { Block, hash, uint256 } from "./deps.ts"; +import { STARTING_BLOCK, LAUNCHPAD_ADDRESS } from "./constants.ts"; + +const filter = { + header: { + weak: true + }, + events: [ + { + fromAddress: LAUNCHPAD_ADDRESS.SEPOLIA, + keys: [hash.getSelectorFromName("SellToken")], + includeReceipt: false + } + ] +}; + +export const config = { + streamUrl: "https://sepolia.starknet.a5a.ch", + startingBlock: STARTING_BLOCK, + network: "starknet", + finality: "DATA_STATUS_ACCEPTED", + filter, + sinkType: "postgres", + sinkOptions: { + connectionString: Deno.env.get("POSTGRES_CONNECTION_STRING"), // Your PostgreSQL connection string + tableName: "token_transactions" // Using the same table for buy and sell + } +}; + +export default function DecodeSellToken({ header, events }: Block) { + const { blockNumber, blockHash, timestamp: block_timestamp } = header!; + + return (events ?? []).map(({ event, transaction }) => { + if (!event.data) return; + + const transactionHash = transaction.meta.hash; + const transfer_id = `${transactionHash}_${event.index}`; + + const [caller, token_address] = event.keys!; + + const [ + amount_low, + amount_high, + price_low, + price_high, + protocol_fee_low, + protocol_fee_high, + last_price_low, + last_price_high, + timestamp, + quote_amount_low, + quote_amount_high + ] = event.data; + + const amount = uint256 + .uint256ToBN({ low: amount_low, high: amount_high }) + .toString(); + const price = uint256 + .uint256ToBN({ low: price_low, high: price_high }) + .toString(); + const protocol_fee = uint256 + .uint256ToBN({ low: protocol_fee_low, high: protocol_fee_high }) + .toString(); + const last_price = uint256 + .uint256ToBN({ low: last_price_low, high: last_price_high }) + .toString(); + const quote_amount = uint256 + .uint256ToBN({ low: quote_amount_low, high: quote_amount_high }) + .toString(); + + return { + transfer_id, + network: "starknet-sepolia", + block_hash: blockHash, + block_number: Number(blockNumber), + block_timestamp: block_timestamp, + transaction_hash: transactionHash, + memecoin_address: token_address, + owner_address: caller, + last_price, + quote_amount, + price, + amount, + protocol_fee, + time_stamp: timestamp, + transaction_type: "sell" + }; + }); +} diff --git a/apps/indexer/src/token-launch.js b/apps/indexer/src/token-launch.js deleted file mode 100644 index 559db270..00000000 --- a/apps/indexer/src/token-launch.js +++ /dev/null @@ -1,83 +0,0 @@ -// import { Block, hash, shortString, uint256 } from './deps.js' -import { FACTORY_ADDRESS, LAUNCHPAD_ADDRESS, STARTING_BLOCK } from './constants.js' -import { hash, uint256, shortString, cairo } from "https://esm.run/starknet@5.14"; -import { formatUnits } from "https://esm.run/viem@1.4"; - -const filter = { - header: { - weak: true, - }, - events: [ - { - fromAddress: LAUNCHPAD_ADDRESS.SEPOLIA, - keys: [hash.getSelectorFromName('CreateLaunch')], - includeReceipt: false, - }, - ], -} - -export const config = { - streamUrl: 'https://sepolia.starknet.a5a.ch', - startingBlock: STARTING_BLOCK, - network: 'starknet', - finality: 'DATA_STATUS_ACCEPTED', - filter, - sinkType: 'postgres', - sinkOptions: { - connectionString: '', - tableName: 'token_launch', - }, -} - -export default function DecodeTokenLaunchDeploy({ header, events }) { - const { blockNumber, blockHash, timestamp } = header; - - return (events ?? []).map(({ event, transaction }) => { - if (!event.data) return - - const transactionHash = transaction.meta.hash - console.log("event data", event?.data) - - const [owner, token_address, ] = event.keys - const [name, symbol, initial_supply_low, initial_supply_high, total_supply_low, total_supply_high] = event.data - console.log("owner", owner) - console.log("token_address", token_address) - console.log("name", name) - console.log("symbol", symbol) - console.log("initial_supply_low", initial_supply_low) - console.log("total_supply_low", total_supply_low) - - const name_decoded = shortString.decodeShortString(name.replace(/0x0+/, '0x')) - const symbol_decoded = shortString.decodeShortString(symbol.replace(/0x0+/, '0x')) - const quote_token_decoded = token_address ? shortString.decodeShortString(token_address.replace(/0x0+/, '0x')) : ''; - const exchange_name_decoded = exchange_name ? shortString.decodeShortString(exchange_name.replace(/0x0+/, '0x')) : ''; - const price_decoded = price ? shortString.decodeShortString(price.replace(/0x0+/, '0x')) : ''; - const liquidity_raised_decoded = liquidity_raised ? uint256.uint256ToBN({ low: liquidity_raised, high: 0 }).toString() : '0'; - - let total_supply= cairo.uint256(0) - if(total_supply_high && total_supply_low) { - total_supply = uint256.uint256ToBN({ low: total_supply_low, high: total_supply_high }).toString() - - } - console.log("total_supply", total_supply) - - return { - network: 'starknet-sepolia', - block_hash: blockHash, - block_number: Number(blockNumber), - block_timestamp: timestamp, - transaction_hash: transactionHash, - memecoin_address: token_address, - quote_token: quote_token_decoded, - exchange_name: exchange_name_decoded, - created_at: new Date().toISOString(), - total_supply, - current_supply, - liquidity_raised: liquidity_raised_decoded, - price: price_decoded, - _cursor: transaction.meta.cursor, - timestamp: new Date(timestamp * 1000).toISOString(), - - } - }) -} diff --git a/apps/indexer/src/token-launch.ts b/apps/indexer/src/token-launch.ts new file mode 100644 index 00000000..f013d8d6 --- /dev/null +++ b/apps/indexer/src/token-launch.ts @@ -0,0 +1,86 @@ +import { + FACTORY_ADDRESS, + LAUNCHPAD_ADDRESS, + STARTING_BLOCK +} from "./constants.ts"; +import { Block, hash, uint256, shortString, cairo } from "./deps.ts"; + +const filter = { + header: { + weak: true + }, + events: [ + { + fromAddress: LAUNCHPAD_ADDRESS.SEPOLIA, + keys: [hash.getSelectorFromName("CreateLaunch")], + includeReceipt: false + } + ] +}; + +export const config = { + streamUrl: "https://sepolia.starknet.a5a.ch", + startingBlock: STARTING_BLOCK, + network: "starknet", + finality: "DATA_STATUS_ACCEPTED", + filter, + sinkType: "postgres", + sinkOptions: { + connectionString: Deno.env.get("POSTGRES_CONNECTION_STRING"), + tableName: "token_launch" + } +}; + +export default function DecodeTokenLaunchDeploy({ header, events }: Block) { + const { blockNumber, blockHash, timestamp } = header!; + + return (events ?? []).map(({ event, transaction }) => { + if (!event.data) return; + + const transactionHash = transaction.meta.hash; + + const [caller, token_address] = event.keys!; + const [ + amount_low, + amount_high, + price, + total_supply_low, + total_supply_high, + slope_low, + slope_high, + threshold_liquidity_low, + threshold_liquidity_high + ] = event.data; + + const amount = uint256 + .uint256ToBN({ low: amount_low, high: amount_high }) + .toString(); + const price_decoded = price + ? shortString.decodeShortString(price.replace(/0x0+/, "0x")) + : ""; + const total_supply = uint256 + .uint256ToBN({ low: total_supply_low, high: total_supply_high }) + .toString(); + const slope = uint256 + .uint256ToBN({ low: slope_low, high: slope_high }) + .toString(); + const threshold_liquidity = uint256 + .uint256ToBN({ + low: threshold_liquidity_low, + high: threshold_liquidity_high + }) + .toString(); + + return { + memecoin_address: token_address, + network: "starknet-sepolia", + block_hash: blockHash, + block_number: Number(blockNumber), + block_timestamp: timestamp, + transaction_hash: transactionHash, + total_supply, + price: price_decoded, + time_stamp: timestamp + }; + }); +} diff --git a/apps/indexer/src/transfer.js b/apps/indexer/src/transfer.ts similarity index 64% rename from apps/indexer/src/transfer.js rename to apps/indexer/src/transfer.ts index c18c1096..a2f760b1 100644 --- a/apps/indexer/src/transfer.js +++ b/apps/indexer/src/transfer.ts @@ -1,5 +1,4 @@ -import { hash, uint256 } from "https://esm.run/starknet@5.14"; -import { formatUnits } from "https://esm.run/viem@1.4"; +import { hash, uint256, formatUnits, Block } from "./deps.ts"; const DECIMALS = 18; @@ -13,39 +12,40 @@ export const config = { { fromAddress: "0x03e85bfbb8e2a42b7bead9e88e9a1b19dbccf661471061807292120462396ec9", - keys: [hash.getSelectorFromName("Transfer")], - }, - ], + keys: [hash.getSelectorFromName("Transfer")] + } + ] }, sinkType: "postgres", sinkOptions: { - tableName: "transfers", - }, + connectionString: Deno.env.get("POSTGRES_CONNECTION_STRING"), + tableName: "transfers" + } }; -export default function transform({ header, events }) { - const { blockNumber, blockHash, timestamp } = header; - return events.map(({ event, receipt }) => { +export default function transform({ header, events }: Block) { + const { blockNumber, blockHash, timestamp } = header!; + + return (events ?? []).map(({ event, receipt }) => { const { transactionHash } = receipt; const transferId = `${transactionHash}_${event.index}`; - const [fromAddress, toAddress, amountLow, amountHigh] = event.data; + const [fromAddress, toAddress, amountLow, amountHigh] = event.data!; const amountRaw = uint256.uint256ToBN({ low: amountLow, high: amountHigh }); const amount = formatUnits(amountRaw, DECIMALS); - // Convert to snake_case because it works better with postgres. return { network: "starknet-sepolia", symbol: "ETH", block_hash: blockHash, - block_number: +blockNumber, + block_number: Number(blockNumber), block_timestamp: timestamp, transaction_hash: transactionHash, transfer_id: transferId, from_address: fromAddress, to_address: toAddress, amount: amount, - amount_raw: amountRaw.toString(), + amount_raw: amountRaw.toString() }; }); } diff --git a/apps/indexer/src/unruggableMemecoin-deploy.indexer.js b/apps/indexer/src/unruggableMemecoin-deploy.indexer.js deleted file mode 100644 index afd86604..00000000 --- a/apps/indexer/src/unruggableMemecoin-deploy.indexer.js +++ /dev/null @@ -1,58 +0,0 @@ -import { hash, shortString, uint256 } from './deps.ts' -import { FACTORY_ADDRESS, STARTING_BLOCK } from './constants.ts' - -const filter = { - header: { - weak: true, - }, - events: [ - { - fromAddress: FACTORY_ADDRESS, - keys: [hash.getSelectorFromName('MemecoinCreated')], - includeReceipt: false, - }, - ], -} - -export const config = { - streamUrl: 'https://mainnet.starknet.a5a.ch', - startingBlock: STARTING_BLOCK, - network: 'starknet', - finality: 'DATA_STATUS_ACCEPTED', - filter, - sinkType: 'postgres', - sinkOptions: { - connectionString: '', - tableName: 'unrugmeme_deploy', - }, -} - -export default function DecodeUnruggableMemecoinDeploy({ header, events }) { - const { blockNumber, blockHash, timestamp } = header - - return (events ?? []).map(({ event, transaction }) => { - if (!event.data) return - - const transactionHash = transaction.meta.hash - - const [owner, name, symbol, initial_supply_low, initial_supply_high, memecoin_address] = event.data - - const name_decoded = shortString.decodeShortString(name.replace(/0x0+/, '0x')) - const symbol_decoded = shortString.decodeShortString(symbol.replace(/0x0+/, '0x')) - const initial_supply = uint256.uint256ToBN({ low: initial_supply_low, high: initial_supply_high }).toString() - - return { - network: 'starknet-mainnet', - block_hash: blockHash, - block_number: Number(blockNumber), - block_timestamp: timestamp, - transaction_hash: transactionHash, - memecoin_address: memecoin_address, - owner_address: owner, - name: name_decoded, - symbol: symbol_decoded, - initial_supply: initial_supply, - created_at: new Date().toISOString(), - } - }) -} diff --git a/apps/indexer/src/unruggableMemecoin-deploy.indexer.ts b/apps/indexer/src/unruggableMemecoin-deploy.indexer.ts new file mode 100644 index 00000000..3e1abbed --- /dev/null +++ b/apps/indexer/src/unruggableMemecoin-deploy.indexer.ts @@ -0,0 +1,74 @@ +import { Block, hash, shortString, uint256 } from "./deps.ts"; +import { FACTORY_ADDRESS, STARTING_BLOCK } from "./constants.ts"; + +const filter = { + header: { + weak: true + }, + events: [ + { + fromAddress: FACTORY_ADDRESS, + keys: [hash.getSelectorFromName("MemecoinCreated")], + includeReceipt: false + } + ] +}; + +export const config = { + streamUrl: "https://mainnet.starknet.a5a.ch", + startingBlock: STARTING_BLOCK, + network: "starknet", + finality: "DATA_STATUS_ACCEPTED", + filter, + sinkType: "postgres", + sinkOptions: { + connectionString: Deno.env.get("POSTGRES_CONNECTION_STRING"), + tableName: "unrugmeme_deploy" + } +}; + +export default function DecodeUnruggableMemecoinDeploy({ + header, + events +}: Block) { + const { blockNumber, blockHash, timestamp } = header!; + + return (events ?? []).map(({ event, transaction }) => { + if (!event.data) return; + + const transactionHash = transaction.meta.hash; + + const [ + owner, + name, + symbol, + initial_supply_low, + initial_supply_high, + memecoin_address + ] = event.data; + + const name_decoded = shortString.decodeShortString( + name.replace(/0x0+/, "0x") + ); + const symbol_decoded = shortString.decodeShortString( + symbol.replace(/0x0+/, "0x") + ); + const initial_supply = uint256 + .uint256ToBN({ low: initial_supply_low, high: initial_supply_high }) + .toString(); + + return { + network: "starknet-mainnet", + block_hash: blockHash, + block_number: Number(blockNumber), + block_timestamp: timestamp, + transaction_hash: transactionHash, + memecoin_address: memecoin_address, + owner_address: owner, + name: name_decoded, + symbol: symbol_decoded, + initial_supply: initial_supply, + created_at: new Date().toISOString() + }; + }); +} diff --git a/apps/indexer/src/unruggableMemecoin-launch.indexer.js b/apps/indexer/src/unruggableMemecoin-launch.indexer.js deleted file mode 100644 index 6ccb8678..00000000 --- a/apps/indexer/src/unruggableMemecoin-launch.indexer.js +++ /dev/null @@ -1,54 +0,0 @@ -import { Block, hash, shortString } from './deps.js' -import { FACTORY_ADDRESS, STARTING_BLOCK } from './constants.js' - -const filter = { - header: { - weak: true, - }, - events: [ - { - fromAddress: FACTORY_ADDRESS, - keys: [hash.getSelectorFromName('MemecoinLaunched')], - includeReceipt: false, - }, - ], -} - -export const config = { - streamUrl: 'https://mainnet.starknet.a5a.ch', - startingBlock: STARTING_BLOCK, - network: 'starknet', - finality: 'DATA_STATUS_ACCEPTED', - filter, - sinkType: 'postgres', - sinkOptions: { - connectionString: '', - tableName: 'unrugmeme_launch', - }, -} - -export default function DecodeUnruggableMemecoinLaunch({ header, events }) { - const { blockNumber, blockHash, timestamp } = header - - return (events ?? []).map(({ event, transaction }) => { - if (!event.data) return - - const transactionHash = transaction.meta.hash - - const [memecoin_address, quote_token, exchange_name] = event.data - - const exchange_name_decoded = shortString.decodeShortString(exchange_name.replace(/0x0+/, '0x')) - - return { - network: 'starknet-mainnet', - block_hash: blockHash, - block_number: Number(blockNumber), - block_timestamp: timestamp, - transaction_hash: transactionHash, - memecoin_address: memecoin_address, - quote_token: quote_token, - exchange_name: exchange_name_decoded, - created_at: new Date().toISOString(), - } - }) -} diff --git a/apps/indexer/src/unruggableMemecoin-launch.indexer.ts b/apps/indexer/src/unruggableMemecoin-launch.indexer.ts new file mode 100644 index 00000000..31a957a1 --- /dev/null +++ b/apps/indexer/src/unruggableMemecoin-launch.indexer.ts @@ -0,0 +1,59 @@ +import { Block, hash, shortString } from "./deps.ts"; +import { FACTORY_ADDRESS, STARTING_BLOCK } from "./constants.ts"; + +const filter = { + header: { + weak: true + }, + events: [ + { + fromAddress: FACTORY_ADDRESS, + keys: [hash.getSelectorFromName("MemecoinLaunched")], + includeReceipt: false + } + ] +}; + +export const config = { + streamUrl: "https://mainnet.starknet.a5a.ch", + startingBlock: STARTING_BLOCK, + network: "starknet", + finality: "DATA_STATUS_ACCEPTED", + filter, + sinkType: "postgres", + sinkOptions: { + connectionString: Deno.env.get("POSTGRES_CONNECTION_STRING"), + tableName: "unrugmeme_launch" + } +}; + +export default function DecodeUnruggableMemecoinLaunch({ + header, + events +}: Block) { + const { blockNumber, blockHash, timestamp } = header!; + + return (events ?? []).map(({ event, transaction }) => { + if (!event.data) return; + + const transactionHash = transaction.meta.hash; + + const [memecoin_address, quote_token, exchange_name] = event.data; + + const exchange_name_decoded = shortString.decodeShortString( + exchange_name.replace(/0x0+/, "0x") + ); + + return { + network: "starknet-mainnet", + block_hash: blockHash, + block_number: Number(blockNumber), + block_timestamp: timestamp, + transaction_hash: transactionHash, + memecoin_address: memecoin_address, + quote_token: quote_token, + exchange_name: exchange_name_decoded, + created_at: new Date().toISOString() + }; + }); +} diff --git a/apps/indexer/src/unruggableMemecoin-transfers.indexer.js b/apps/indexer/src/unruggableMemecoin-transfers.indexer.js deleted file mode 100644 index c5ab99fe..00000000 --- a/apps/indexer/src/unruggableMemecoin-transfers.indexer.js +++ /dev/null @@ -1,71 +0,0 @@ -import { Block, hash, uint256 } from './deps.js' -import { FACTORY_ADDRESS, STARTING_BLOCK } from './constants.js' - -export const config = { - filter: { - header: { weak: true }, - events: [ - { - fromAddress: FACTORY_ADDRESS, - keys: [hash.getSelectorFromName('MemecoinLaunched')], - includeReceipt: false, - }, - ], - }, - streamUrl: 'https://mainnet.starknet.a5a.ch', - startingBlock: STARTING_BLOCK, - network: 'starknet', - finality: 'DATA_STATUS_ACCEPTED', - sinkType: 'postgres', - sinkOptions: { - connectionString: '', - tableName: 'unrugmeme_transfers', - }, -} - -export function factory({ header, events }) { - const launchEvents = (events ?? []).map(({ event }) => { - const memecoin_address = event.data?.[0] - return { - fromAddress: memecoin_address, - keys: [hash.getSelectorFromName('Transfer')], - includeReceipt: false, - } - }) - - return { - filter: { - header: { weak: true }, - events: launchEvents, - }, - } -} - -export default function DecodeUnruggableMemecoinLaunch({ header, events }) { - const { blockNumber, blockHash, timestamp } = header - - return (events ?? []).map(({ event, transaction }) => { - if (!event.data || !event.keys) return - - const transactionHash = transaction.meta.hash - const transferId = `${transactionHash}_${event.index ?? 0}` - const fromAddress = event.keys[1] - const toAddress = event.keys[2] - const amount = uint256.uint256ToBN({ low: event.data[0], high: event.data[1] }) - const memecoin_address = event.fromAddress - - return { - network: 'starknet-mainnet', - block_hash: blockHash, - block_number: Number(blockNumber), - block_timestamp: timestamp, - transaction_hash: transactionHash, - transfer_id: transferId, - from_address: fromAddress, - to_address: toAddress, - memecoin_address: memecoin_address, - amount: amount.toString(10), - created_at: new Date().toISOString(), - } - }) -} diff --git a/apps/indexer/src/unruggableMemecoin-transfers.indexer.ts b/apps/indexer/src/unruggableMemecoin-transfers.indexer.ts new file mode 100644 index 00000000..73198cac --- /dev/null +++ b/apps/indexer/src/unruggableMemecoin-transfers.indexer.ts @@ -0,0 +1,77 @@ +import { Block, hash, uint256 } from "./deps.ts"; +import { FACTORY_ADDRESS, STARTING_BLOCK } from "./constants.ts"; + +export const config = { + filter: { + header: { weak: true }, + events: [ + { + fromAddress: FACTORY_ADDRESS, + keys: [hash.getSelectorFromName("MemecoinLaunched")], + includeReceipt: false + } + ] + }, + streamUrl: "https://mainnet.starknet.a5a.ch", + startingBlock: STARTING_BLOCK, + network: "starknet", + finality: "DATA_STATUS_ACCEPTED", + sinkType: "postgres", + sinkOptions: { + connectionString: Deno.env.get("POSTGRES_CONNECTION_STRING"), + tableName: "unrugmeme_transfers" + } +}; + +export function factory({ header, events }: Block) { + const launchEvents = (events ?? []).map(({ event }) => { + const memecoin_address = event.data?.[0]; + return { + fromAddress: memecoin_address, + keys: [hash.getSelectorFromName("Transfer")], + includeReceipt: false + }; + }); + + return { + filter: { + header: { weak: true }, + events: launchEvents + } + }; +} + +export default function DecodeUnruggableMemecoinLaunch({ + header, + events +}: Block) { + const { blockNumber, blockHash, timestamp } = header!; + + return (events ?? []).map(({ event, transaction }) => { + if (!event.data || !event.keys) return; + + const transactionHash = transaction.meta.hash; + const transferId = `${transactionHash}_${event.index ?? 0}`; + const fromAddress = event.keys[1]; + const toAddress = event.keys[2]; + const amount = uint256.uint256ToBN({ + low: event.data[0], + high: event.data[1] + }); + const memecoin_address = event.fromAddress; + + return { + network: "starknet-mainnet", + block_hash: blockHash, + block_number: Number(blockNumber), + block_timestamp: timestamp, + transaction_hash: transactionHash, + transfer_id: transferId, + from_address: fromAddress, + to_address: toAddress, + memecoin_address: memecoin_address, + amount: amount.toString(10), + created_at: new Date().toISOString() + }; + }); +} diff --git a/apps/mobile/.env.example b/apps/mobile/.env.example index 1d7b842e..b1c09129 100644 --- a/apps/mobile/.env.example +++ b/apps/mobile/.env.example @@ -8,3 +8,11 @@ EXPO_NODE_ENV="development" EXPO_PUBLIC_INDEXER_BACKEND_URL="" EXPO_PUBLIC_PIXEL_URL="http://localhost:3000/pixel" + +# Website Pixel UI package +REACT_APP_BACKEND_URL= +REACT_APP_USERNAME_STORE_CONTRACT_ADDRESS= +REACT_APP_CANVAS_NFT_CONTRACT_ADDRESS= +REACT_APP_USERNAME_STORE_CONTRACT_ADDRESS= +REACT_APP_NODE_ENV= + diff --git a/apps/mobile/src/app/Router.tsx b/apps/mobile/src/app/Router.tsx index b5372f7a..ce095180 100644 --- a/apps/mobile/src/app/Router.tsx +++ b/apps/mobile/src/app/Router.tsx @@ -1,6 +1,6 @@ import {createBottomTabNavigator} from '@react-navigation/bottom-tabs'; import {createDrawerNavigator} from '@react-navigation/drawer'; -import {NavigationContainer} from '@react-navigation/native'; +import {NavigationContainer, useNavigation, useRoute} from '@react-navigation/native'; import {createNativeStackNavigator} from '@react-navigation/native-stack'; import {useAuth} from 'afk_nostr_sdk'; import {useEffect, useMemo, useState} from 'react'; @@ -10,6 +10,7 @@ import {Icon} from '../components'; import {Navbar} from '../components/Navbar'; import {useStyles, useTheme} from '../hooks'; import GroupChatDetail from '../modules/Group/groupDetail/GroupChatDetail'; +import GroupChatGroupRequest from '../modules/Group/memberAction/ViewRequest'; import GroupChat from '../modules/Group/message/GroupMessage'; import AuthSidebar from '../modules/Layout/auth-sidebar'; import DegensSidebar from '../modules/Layout/degens-sidebar'; @@ -45,6 +46,7 @@ import { RootStackParams, } from '../types'; import {retrievePublicKey} from '../utils/storage'; +import RightSidebar from '../components/RightSideBar'; const DrawerStack = createDrawerNavigator(); const RootStack = createNativeStackNavigator(); const AuthStack = createDrawerNavigator(); @@ -196,8 +198,8 @@ const AuthNavigator: React.FC = () => { drawerContent={(props) => } screenOptions={({navigation}) => ({ // headerShown:false, - header: () => , - headerShown: false, + header: () => (!isDesktop ? : null), + headerShown: !isDesktop, headerStyle: { backgroundColor: theme.theme.colors.background, }, @@ -260,6 +262,17 @@ const MainNavigator: React.FC = () => { const theme = useTheme(); + const FeedWithSidebar: React.FC = () => ( + + + + + + + + + ); + return ( { drawerContent={(props) => } screenOptions={({navigation}) => ({ // headerShown:false, - header: () => , + header: () => (!isDesktop ? : null), + headerShown: !isDesktop, headerStyle: { backgroundColor: theme.theme.colors.background, }, @@ -283,10 +297,11 @@ const MainNavigator: React.FC = () => { }, })} > + {!isDesktop ? ( ) : ( - + )} {isDesktop && ( { + + diff --git a/apps/mobile/src/assets/icons.tsx b/apps/mobile/src/assets/icons.tsx index b1c79a34..db5bd738 100644 --- a/apps/mobile/src/assets/icons.tsx +++ b/apps/mobile/src/assets/icons.tsx @@ -23,6 +23,16 @@ export const AdminIcon: React.FC = (props) => ( /> ); + +export const EditIcon: React.FC = (props) => ( + + + +); + export const CrownIcon: React.FC = (props) => ( = (props) => ( ); +export const TrashIcon: React.FC = (props) => ( + + + + + + + +); + export const RemoveIcon: React.FC = (props) => ( = (props) => { ); }; - export const LikeIcon: React.FC = (props) => { return ( @@ -388,15 +415,20 @@ export const LikeIcon: React.FC = (props) => { export const BookmarkIcon: React.FC = (props) => ( ); +export const BookmarkFillIcon: React.FC = (props) => ( + + + +); + export const LikeFillIcon: React.FC = (props) => { return ( diff --git a/apps/mobile/src/components/Filter/index.tsx b/apps/mobile/src/components/Filter/index.tsx index b517e409..69179c61 100644 --- a/apps/mobile/src/components/Filter/index.tsx +++ b/apps/mobile/src/components/Filter/index.tsx @@ -11,8 +11,8 @@ import { } from 'react-native'; import {useStyles} from '../../hooks'; +import {SORT_OPTION_EVENT_NOSTR} from '../../types/nostr'; import stylesheet from './styles'; -import { SORT_OPTION_EVENT_NOSTR } from '../../types/nostr'; interface IFilterMenuProps { visible: boolean; @@ -31,7 +31,6 @@ const NDK_KIND_OPTIONS = [ {label: 'Metadata', value: NDKKind.Metadata}, ]; - // const SORT_OPTIONS = [ // {label: 'Time', value: 'time'}, // {label: 'For You', value: 'forYou'}, @@ -78,7 +77,10 @@ const FilterMenu: React.FC = ({ {SORT_OPTIONS.map((option) => ( onSortChange(option.value.toString())} > {option.label} diff --git a/apps/mobile/src/components/HeaderScreen/index.tsx b/apps/mobile/src/components/HeaderScreen/index.tsx index 741ae898..46ad86b2 100644 --- a/apps/mobile/src/components/HeaderScreen/index.tsx +++ b/apps/mobile/src/components/HeaderScreen/index.tsx @@ -2,10 +2,10 @@ import {Image, View} from 'react-native'; import {SafeAreaView} from 'react-native-safe-area-context'; import {useStyles, useTheme} from '../../hooks'; +import {Spacing} from '../../styles'; +import {Divider} from '../Divider'; import {Text} from '../Text'; import stylesheet from './styles'; -import {Divider} from '../Divider'; -import {Spacing} from '../../styles'; export type HeaderProps = { showLogo?: boolean; diff --git a/apps/mobile/src/components/HeaderScreen/styles.ts b/apps/mobile/src/components/HeaderScreen/styles.ts index ba236ddc..ab81051b 100644 --- a/apps/mobile/src/components/HeaderScreen/styles.ts +++ b/apps/mobile/src/components/HeaderScreen/styles.ts @@ -1,5 +1,3 @@ -import {StyleSheet} from 'react-native'; - import {Spacing, ThemedStyleSheet} from '../../styles'; export default ThemedStyleSheet((theme) => ({ diff --git a/apps/mobile/src/components/Input/styles.ts b/apps/mobile/src/components/Input/styles.ts index b95b2a48..7306b8a1 100644 --- a/apps/mobile/src/components/Input/styles.ts +++ b/apps/mobile/src/components/Input/styles.ts @@ -1,7 +1,7 @@ import {Spacing, ThemedStyleSheet, Typography} from '../../styles'; export default ThemedStyleSheet( - (theme, error: boolean, left: boolean, right: boolean, paddingRight: boolean = true) => ({ + (theme, error: boolean, left: boolean, right: boolean, paddingRight = true) => ({ container: { width: '100%', }, diff --git a/apps/mobile/src/components/Navbar/index.tsx b/apps/mobile/src/components/Navbar/index.tsx index 3e36633f..e5e552e0 100644 --- a/apps/mobile/src/components/Navbar/index.tsx +++ b/apps/mobile/src/components/Navbar/index.tsx @@ -1,15 +1,15 @@ import * as React from 'react'; -import { Image, Text, TouchableOpacity, View } from 'react-native'; +import {Image, Text, TouchableOpacity, View} from 'react-native'; -import { useStyles, useWindowDimensions } from '../../hooks'; -import { Icon } from '../Icon'; +import {useStyles, useWindowDimensions} from '../../hooks'; +import {Icon} from '../Icon'; import stylesheet from './styles'; interface CustomHeaderInterface { title?: string; navigation?: any; showLogo?: boolean; } -export const Navbar = ({ title, navigation, showLogo }: CustomHeaderInterface) => { +export const Navbar = ({title, navigation, showLogo}: CustomHeaderInterface) => { const styles = useStyles(stylesheet); const dimensions = useWindowDimensions(); const isDesktop = React.useMemo(() => { @@ -27,18 +27,17 @@ export const Navbar = ({ title, navigation, showLogo }: CustomHeaderInterface) = )} {title} - {!isDesktop && + {!isDesktop && ( navigation?.openDrawer()} style={styles.burgerIcon}> - } + )} {/* {isDesktop && navigation?.openDrawer()} style={styles.burgerIcon}> } */} - ); }; diff --git a/apps/mobile/src/components/PrivateKeyImport/index.tsx b/apps/mobile/src/components/PrivateKeyImport/index.tsx index f8673afc..a56c0cd1 100644 --- a/apps/mobile/src/components/PrivateKeyImport/index.tsx +++ b/apps/mobile/src/components/PrivateKeyImport/index.tsx @@ -5,18 +5,14 @@ import {useState} from 'react'; import {TouchableOpacity, View} from 'react-native'; import {CopyIconStack, LockIcon} from '../../assets/icons'; +import {useWindowDimensions} from '../../hooks'; import {useToast} from '../../hooks/modals'; import {getPublicKeyFromSecret} from '../../utils/keypair'; -import { - retrieveAndDecryptPrivateKey, - retrievePassword, - retrievePublicKey, -} from '../../utils/storage'; +import {retrieveAndDecryptPrivateKey, retrievePublicKey} from '../../utils/storage'; import {Button} from '../Button'; import {Input} from '../Input'; import {Text} from '../Text'; import styles from './styles'; -import {useWindowDimensions} from '../../hooks'; export const PrivateKeyImport: React.FC = () => { const {publicKey, isExtension, privateKey, setAuth} = useAuth(); @@ -37,11 +33,13 @@ export const PrivateKeyImport: React.FC = () => { showToast({type: 'error', title: 'Password is required'}); return; } - const passwordRetrieve = await retrievePassword(); - if (password != passwordRetrieve) { - showToast({type: 'error', title: 'Invalid password'}); - return; - } + + /** TODO fix in web */ + // const passwordRetrieve = await retrievePassword(); + // if (password != passwordRetrieve) { + // showToast({type: 'error', title: 'Invalid password'}); + // return; + // } const privateKey = await retrieveAndDecryptPrivateKey(password); if (!privateKey || privateKey.length !== 32) { showToast({type: 'error', title: 'Invalid password'}); diff --git a/apps/mobile/src/components/PrivateMessages/Chat/index.tsx b/apps/mobile/src/components/PrivateMessages/Chat/index.tsx index 9b3f2047..77a8aa80 100644 --- a/apps/mobile/src/components/PrivateMessages/Chat/index.tsx +++ b/apps/mobile/src/components/PrivateMessages/Chat/index.tsx @@ -1,42 +1,47 @@ import React from 'react'; -import { ConversationType } from '../../../types/messages'; -import { useStyles } from '../../../hooks'; -import { View, Image, Text } from 'react-native'; -import { MessageInput } from '../PrivateMessageInput'; -import { MessagesList } from '../MessagesList.tsx'; +import {Image, Text, View} from 'react-native'; + +import {useStyles} from '../../../hooks'; +import {ConversationType} from '../../../types/messages'; +import {IconButton} from '../../IconButton'; +import {MessagesList} from '../MessagesList.tsx'; +import {MessageInput} from '../PrivateMessageInput'; import stylesheet from './styles'; -import { IconButton } from '../../IconButton'; export type ChatProps = { - conversation: ConversationType; - handleGoBack: () => void; + conversation: ConversationType; + handleGoBack: () => void; }; -export const Chat: React.FC = ({ conversation, handleGoBack }) => { - - const styles = useStyles(stylesheet); - const user = conversation.user; - const avatar = user.avatar ? {uri: user.avatar } : require('../../../assets/pepe-logo.png'); +export const Chat: React.FC = ({conversation, handleGoBack}) => { + const styles = useStyles(stylesheet); + const user = conversation.user; + const avatar = user.avatar ? {uri: user.avatar} : require('../../../assets/pepe-logo.png'); - const handleSendMessage = (message: string) => { - //todo: integrate hook here - //todo: encrypt message - //todo: send message - }; + const handleSendMessage = (message: string) => { + //todo: integrate hook here + //todo: encrypt message + //todo: send message + }; - return ( - <> - - - - - {user.name} - - - - - - - - ); + return ( + <> + + + + + {user.name} + + + + + + + + ); }; diff --git a/apps/mobile/src/components/PrivateMessages/Chat/styles.ts b/apps/mobile/src/components/PrivateMessages/Chat/styles.ts index cc7b3ae9..de16ba95 100644 --- a/apps/mobile/src/components/PrivateMessages/Chat/styles.ts +++ b/apps/mobile/src/components/PrivateMessages/Chat/styles.ts @@ -1,36 +1,36 @@ -import { ThemedStyleSheet } from "../../../styles"; +import {ThemedStyleSheet} from '../../../styles'; export default ThemedStyleSheet((theme) => ({ - container: { - flex: 1, - backgroundColor: theme.colors.background, - }, - header: { - padding: 10, - borderBottomWidth: 1, - borderBottomColor: theme.colors.divider, - alignItems: 'center', - justifyContent: 'center', - position: 'relative', - }, - headerContent: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - }, - backButton: { - position: 'absolute', - left: 10, - }, - avatar: { - width: 50, - height: 50, - borderRadius: 25, - marginBottom: 5, - }, - name: { - fontSize: 16, - fontWeight: 'bold', - color: theme.colors.text, - }, + container: { + flex: 1, + backgroundColor: theme.colors.background, + }, + header: { + padding: 10, + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + alignItems: 'center', + justifyContent: 'center', + position: 'relative', + }, + headerContent: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + backButton: { + position: 'absolute', + left: 10, + }, + avatar: { + width: 50, + height: 50, + borderRadius: 25, + marginBottom: 5, + }, + name: { + fontSize: 16, + fontWeight: 'bold', + color: theme.colors.text, + }, })); diff --git a/apps/mobile/src/components/PrivateMessages/Conversation/index.tsx b/apps/mobile/src/components/PrivateMessages/Conversation/index.tsx index f2763bb2..25bdf9ea 100644 --- a/apps/mobile/src/components/PrivateMessages/Conversation/index.tsx +++ b/apps/mobile/src/components/PrivateMessages/Conversation/index.tsx @@ -1,28 +1,28 @@ import React from 'react'; -import { Pressable, View, Text, Image } from 'react-native'; +import {Image, Pressable, Text, View} from 'react-native'; + +import {useStyles} from '../../../hooks'; +import {ConversationType} from '../../../types/messages'; import stylesheet from './styles'; -import { useStyles } from '../../../hooks'; -import { ConversationType } from '../../../types/messages'; export type ConversationPreviewProps = { - conversation: ConversationType; - onPressed: () => void; + conversation: ConversationType; + onPressed: () => void; }; export const Conversation: React.FC = ({conversation, onPressed}) => { + const styles = useStyles(stylesheet); - const styles = useStyles(stylesheet); - - const user = conversation.user; - const avatar = user.avatar ? {uri: user.avatar } : require('../../../assets/pepe-logo.png'); + const user = conversation.user; + const avatar = user.avatar ? {uri: user.avatar} : require('../../../assets/pepe-logo.png'); - return ( - - - - {user.name} - {user.handle} - - - ); + return ( + + + + {user.name} + {user.handle} + + + ); }; diff --git a/apps/mobile/src/components/PrivateMessages/Conversation/styles.ts b/apps/mobile/src/components/PrivateMessages/Conversation/styles.ts index a7b5cc8b..0e33e0e8 100644 --- a/apps/mobile/src/components/PrivateMessages/Conversation/styles.ts +++ b/apps/mobile/src/components/PrivateMessages/Conversation/styles.ts @@ -1,29 +1,29 @@ -import { ThemedStyleSheet } from "../../../styles"; +import {ThemedStyleSheet} from '../../../styles'; export default ThemedStyleSheet((theme) => ({ - container: { - flexDirection: 'row', - alignItems: 'center', - padding: 10, - borderBottomWidth: 1, - borderBottomColor: theme.colors.divider, - }, - avatar: { - width: 50, - height: 50, - borderRadius: 25, - marginRight: 10, - }, - textContainer: { - flex: 1, - }, - name: { - fontSize: 16, - fontWeight: 'bold', - color: theme.colors.text, - }, - handle: { - fontSize: 14, - color: theme.colors.textSecondary - }, + container: { + flexDirection: 'row', + alignItems: 'center', + padding: 10, + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + }, + avatar: { + width: 50, + height: 50, + borderRadius: 25, + marginRight: 10, + }, + textContainer: { + flex: 1, + }, + name: { + fontSize: 16, + fontWeight: 'bold', + color: theme.colors.text, + }, + handle: { + fontSize: 14, + color: theme.colors.textSecondary, + }, })); diff --git a/apps/mobile/src/components/PrivateMessages/FormPrivateMessage/index.tsx b/apps/mobile/src/components/PrivateMessages/FormPrivateMessage/index.tsx index a7a98494..35c0e8a2 100644 --- a/apps/mobile/src/components/PrivateMessages/FormPrivateMessage/index.tsx +++ b/apps/mobile/src/components/PrivateMessages/FormPrivateMessage/index.tsx @@ -1,104 +1,105 @@ +import {NDKUser} from '@nostr-dev-kit/ndk'; +import {useQueryClient} from '@tanstack/react-query'; +import {useSendPrivateMessage} from 'afk_nostr_sdk'; import React from 'react'; -import { ConversationType } from '../../../types/messages'; -import { useStyles } from '../../../hooks'; -import { View, Image, Text } from 'react-native'; -import { MessagesList } from '../MessagesList.tsx'; -import stylesheet from './styles'; -import { IconButton } from '../../IconButton'; -import { useSendPrivateMessage } from 'afk_nostr_sdk'; -import { NDKUser } from '@nostr-dev-kit/ndk'; -import { useToast } from '../../../hooks/modals'; -import { useQueryClient } from '@tanstack/react-query'; -import { Input } from '../../Input'; -import { MessageInput } from '../PrivateMessageInput'; -import { KeyboardFixedView } from '../../Skeleton/KeyboardFixedView'; -import { Divider } from '../../Divider'; +import {View} from 'react-native'; +import {useStyles} from '../../../hooks'; +import {useToast} from '../../../hooks/modals'; +import {Divider} from '../../Divider'; +import {IconButton} from '../../IconButton'; +import {Input} from '../../Input'; +import {KeyboardFixedView} from '../../Skeleton/KeyboardFixedView'; +import stylesheet from './styles'; interface IFormPrivateMessage { - publicKey?: string; - user?: NDKUser; - receiverPublicKeyProps?: string + publicKey?: string; + user?: NDKUser; + receiverPublicKeyProps?: string; } -export const FormPrivateMessage: React.FC = ({ user, publicKey, receiverPublicKeyProps }) => { - const styles = useStyles(stylesheet); - const avatar = user?.profile?.banner ?? require('../../../assets/pepe-logo.png'); - - const [receiverPublicKey, setReceiverPublicKey] = React.useState(receiverPublicKeyProps) - const [content, setContent] = React.useState() - const [message, setMessage] = React.useState() - const sendPrivateMessage = useSendPrivateMessage() - const { showToast } = useToast() - const queryClient = useQueryClient(); - - const sendMessage = async (message: string) => { - - // if (!message) { - // showToast({ title: "Please add a content", type: "error" }) - // return; - // } - - if (!receiverPublicKey) { - showToast({ title: "Please choose a Nostr public key", type: "error" }) - return; - } +export const FormPrivateMessage: React.FC = ({ + user, + publicKey, + receiverPublicKeyProps, +}) => { + const styles = useStyles(stylesheet); + const avatar = user?.profile?.banner ?? require('../../../assets/pepe-logo.png'); - //todo: integrate hook here - //todo: encrypt message - //todo: send message - await sendPrivateMessage.mutateAsync( - { receiverPublicKeyProps: receiverPublicKey, content: message, }, - { - onSuccess: () => { - showToast({title:"Message sent", type:"success"}) + const [receiverPublicKey, setReceiverPublicKey] = React.useState(receiverPublicKeyProps); + const [content, setContent] = React.useState(); + const [message, setMessage] = React.useState(); + const sendPrivateMessage = useSendPrivateMessage(); + const {showToast} = useToast(); + const queryClient = useQueryClient(); - }, - }, - ); + const sendMessage = async (message: string) => { + // if (!message) { + // showToast({ title: "Please add a content", type: "error" }) + // return; + // } - }; + if (!receiverPublicKey) { + showToast({title: 'Please choose a Nostr public key', type: 'error'}); + return; + } - const handleSendMessage = () => { + //todo: integrate hook here + //todo: encrypt message + //todo: send message + await sendPrivateMessage.mutateAsync( + {receiverPublicKeyProps: receiverPublicKey, content: message}, + { + onSuccess: () => { + showToast({title: 'Message sent', type: 'success'}); + }, + }, + ); + }; - if (!message) { - showToast({ title: "Please add a content", type: "error" }) - return; - } - if (!receiverPublicKey) { - showToast({ title: "Please choose a Nostr public key", type: "error" }) - return; - } + const handleSendMessage = () => { + if (!message) { + showToast({title: 'Please add a content', type: 'error'}); + return; + } + if (!receiverPublicKey) { + showToast({title: 'Please choose a Nostr public key', type: 'error'}); + return; + } - sendMessage(message) - } + sendMessage(message); + }; - return ( - <> - {/* + return ( + <> + {/* {user.name} */} - - - - + + + + - - + + - - - - {/* */} - - - ); + + + + {/* */} + + + ); }; diff --git a/apps/mobile/src/components/PrivateMessages/FormPrivateMessage/styles.ts b/apps/mobile/src/components/PrivateMessages/FormPrivateMessage/styles.ts index 84fa53ae..8515a2a4 100644 --- a/apps/mobile/src/components/PrivateMessages/FormPrivateMessage/styles.ts +++ b/apps/mobile/src/components/PrivateMessages/FormPrivateMessage/styles.ts @@ -1,51 +1,51 @@ -import { Spacing, ThemedStyleSheet } from "../../../styles"; +import {Spacing, ThemedStyleSheet} from '../../../styles'; export default ThemedStyleSheet((theme) => ({ - container: { - flex: 1, - backgroundColor: theme.colors.background, - }, - header: { - padding: 10, - borderBottomWidth: 1, - borderBottomColor: theme.colors.divider, - alignItems: 'center', - justifyContent: 'center', - position: 'relative', - }, - headerContent: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - }, - backButton: { - position: 'absolute', - left: 10, - }, - avatar: { - width: 50, - height: 50, - borderRadius: 25, - marginBottom: 5, - }, - name: { - fontSize: 16, - fontWeight: 'bold', - color: theme.colors.text, - }, - commentInputContainer: { - backgroundColor: theme.colors.surface, - }, - commentInputContent: { - gap: Spacing.small, - flexDirection: 'row', - alignItems: 'center', - paddingVertical: Spacing.xsmall, - paddingHorizontal: Spacing.pagePadding, - backgroundColor: theme.colors.surface, - }, - commentInput: { - flex: 1, - width: 'auto', - }, + container: { + flex: 1, + backgroundColor: theme.colors.background, + }, + header: { + padding: 10, + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + alignItems: 'center', + justifyContent: 'center', + position: 'relative', + }, + headerContent: { + flex: 1, + alignItems: 'center', + justifyContent: 'center', + }, + backButton: { + position: 'absolute', + left: 10, + }, + avatar: { + width: 50, + height: 50, + borderRadius: 25, + marginBottom: 5, + }, + name: { + fontSize: 16, + fontWeight: 'bold', + color: theme.colors.text, + }, + commentInputContainer: { + backgroundColor: theme.colors.surface, + }, + commentInputContent: { + gap: Spacing.small, + flexDirection: 'row', + alignItems: 'center', + paddingVertical: Spacing.xsmall, + paddingHorizontal: Spacing.pagePadding, + backgroundColor: theme.colors.surface, + }, + commentInput: { + flex: 1, + width: 'auto', + }, })); diff --git a/apps/mobile/src/components/PrivateMessages/MessagesList.tsx/index.tsx b/apps/mobile/src/components/PrivateMessages/MessagesList.tsx/index.tsx index e05c0869..70e0ca4c 100644 --- a/apps/mobile/src/components/PrivateMessages/MessagesList.tsx/index.tsx +++ b/apps/mobile/src/components/PrivateMessages/MessagesList.tsx/index.tsx @@ -1,17 +1,18 @@ import React from 'react'; -import { View, Text, FlatList } from 'react-native'; -import { Message } from '../../../types/messages'; +import {FlatList, Text, View} from 'react-native'; + +import {useStyles} from '../../../hooks'; +import {Message} from '../../../types/messages'; import stylesheet from './styles'; -import { useStyles } from '../../../hooks'; export type MessagesListProps = { - messages: Message[]; + messages: Message[]; }; -export const MessagesList: React.FC = ({ messages }) => { +export const MessagesList: React.FC = ({messages}) => { const styles = useStyles(stylesheet); - const renderItem = ({ item }: { item: Message }) => ( + const renderItem = ({item}: {item: Message}) => ( {item.message} diff --git a/apps/mobile/src/components/PrivateMessages/MessagesList.tsx/styles.ts b/apps/mobile/src/components/PrivateMessages/MessagesList.tsx/styles.ts index 7ff8d3c0..cb316889 100644 --- a/apps/mobile/src/components/PrivateMessages/MessagesList.tsx/styles.ts +++ b/apps/mobile/src/components/PrivateMessages/MessagesList.tsx/styles.ts @@ -1,25 +1,25 @@ -import { ThemedStyleSheet } from '../../../styles'; +import {ThemedStyleSheet} from '../../../styles'; export default ThemedStyleSheet((theme) => ({ - list: { - flex: 1, - padding: 10, - }, - messageContainer: { - borderRadius: 10, - padding: 10, - marginVertical: 5, - maxWidth: '80%', - }, - userMessage: { - alignSelf: 'flex-end', - backgroundColor: theme.colors.primary, - }, - otherMessage: { - alignSelf: 'flex-start', - backgroundColor: theme.colors.primaryLight, - }, - messageText: { - color: theme.colors.text, - }, + list: { + flex: 1, + padding: 10, + }, + messageContainer: { + borderRadius: 10, + padding: 10, + marginVertical: 5, + maxWidth: '80%', + }, + userMessage: { + alignSelf: 'flex-end', + backgroundColor: theme.colors.primary, + }, + otherMessage: { + alignSelf: 'flex-start', + backgroundColor: theme.colors.primaryLight, + }, + messageText: { + color: theme.colors.text, + }, })); diff --git a/apps/mobile/src/components/PrivateMessages/PrivateMessageInput/index.tsx b/apps/mobile/src/components/PrivateMessages/PrivateMessageInput/index.tsx index d72b59a8..7c7b7f8c 100644 --- a/apps/mobile/src/components/PrivateMessages/PrivateMessageInput/index.tsx +++ b/apps/mobile/src/components/PrivateMessages/PrivateMessageInput/index.tsx @@ -1,17 +1,18 @@ -import React, { useState } from 'react'; -import { View } from 'react-native'; +import React, {useState} from 'react'; +import {View} from 'react-native'; + +import {useStyles} from '../../../hooks'; +import {Divider} from '../../Divider'; +import {IconButton} from '../../IconButton'; +import {Input} from '../../Input'; +import {KeyboardFixedView} from '../../Skeleton/KeyboardFixedView'; import stylesheet from './styles'; -import { KeyboardFixedView } from '../../Skeleton/KeyboardFixedView'; -import { Divider } from '../../Divider'; -import { Input } from '../../Input'; -import { IconButton } from '../../IconButton'; -import { useStyles } from '../../../hooks'; export type MessageInputProps = { - onSend: (message: string) => void; + onSend: (message: string) => void; }; -export const MessageInput: React.FC = ({ onSend }) => { +export const MessageInput: React.FC = ({onSend}) => { const styles = useStyles(stylesheet); const [message, setMessage] = useState(''); @@ -24,18 +25,18 @@ export const MessageInput: React.FC = ({ onSend }) => { return ( - + - - + + - - - + + + ); }; diff --git a/apps/mobile/src/components/PrivateMessages/PrivateMessageInput/styles.ts b/apps/mobile/src/components/PrivateMessages/PrivateMessageInput/styles.ts index 410ab80c..82622ae8 100644 --- a/apps/mobile/src/components/PrivateMessages/PrivateMessageInput/styles.ts +++ b/apps/mobile/src/components/PrivateMessages/PrivateMessageInput/styles.ts @@ -1,7 +1,7 @@ -import { Spacing, ThemedStyleSheet } from "../../../styles"; +import {Spacing, ThemedStyleSheet} from '../../../styles'; export default ThemedStyleSheet((theme) => ({ - commentInputContainer: { + commentInputContainer: { backgroundColor: theme.colors.surface, }, commentInputContent: { diff --git a/apps/mobile/src/components/RelaysConfig/index.tsx b/apps/mobile/src/components/RelaysConfig/index.tsx index b554159b..383799c0 100644 --- a/apps/mobile/src/components/RelaysConfig/index.tsx +++ b/apps/mobile/src/components/RelaysConfig/index.tsx @@ -115,8 +115,10 @@ export const RelaysConfig: React.FC = () => { fontSize: 12, }} onPress={() => removeRelay(r)} - children="Remove" - /> + // children="Remove" + > + Remove + ); @@ -138,8 +140,10 @@ export const RelaysConfig: React.FC = () => { fontSize: 12, }} onPress={() => addRelay(relayToAdd)} - children="+" - /> + // children="+" + > + + + } paddingRight={false} value={relayToAdd} diff --git a/apps/mobile/src/components/RightSideBar/index.tsx b/apps/mobile/src/components/RightSideBar/index.tsx new file mode 100644 index 00000000..97e645e3 --- /dev/null +++ b/apps/mobile/src/components/RightSideBar/index.tsx @@ -0,0 +1,79 @@ +import React, { useState } from 'react'; +import { View, Text, TouchableOpacity, FlatList, ActivityIndicator } from 'react-native'; +import { useAuth, useProfile } from 'afk_nostr_sdk'; +import { useQueryAllCoins } from '../../hooks/launchpad/useQueryAllCoins'; +import { useStyles, useTheme } from '../../hooks'; +import stylesheet from './styles'; +import { TokenLaunchDetail } from '../../components/pump/TokenLaunchDetail'; +import { TokenLaunchInterface } from '../../types/keys'; + +const tabs = ['Trending', 'Quests']; + +const RightSidebar = () => { + const { theme } = useTheme(); + const styles = useStyles(stylesheet); + const [activeTab, setActiveTab] = useState(tabs[0]); + + const publicKey = useAuth((state) => state.publicKey); + const { data: profile } = useProfile({ publicKey }); + + const { data: coins, isLoading, error } = useQueryAllCoins(); + + const handleTabChange = (tab: string) => { + setActiveTab(tab); + }; + + const renderListItem = ({ item }: { item: TokenLaunchInterface }) => ( + + + + ); + + return ( + + + {tabs.map((tab) => ( + handleTabChange(tab)} + > + {tab} + + ))} + + + {activeTab === 'Trending' && ( + <> + {isLoading ? ( + + ) : error ? ( + Failed to load coins + ) : ( + item.token_address.toString()} + horizontal + showsHorizontalScrollIndicator={false} + ListEmptyComponent={No coins available} + ItemSeparatorComponent={() => } + /> + )} + + )} + {activeTab === 'Quests' && ( + <> + {profile ? ( + NIP05: {profile.nip05} + ) : ( + ... + )} + + )} + + + ); +}; + +export default RightSidebar; diff --git a/apps/mobile/src/components/RightSideBar/styles.ts b/apps/mobile/src/components/RightSideBar/styles.ts new file mode 100644 index 00000000..e1c65ca0 --- /dev/null +++ b/apps/mobile/src/components/RightSideBar/styles.ts @@ -0,0 +1,47 @@ +import { Spacing, ThemedStyleSheet } from '../../styles'; + +export default ThemedStyleSheet((theme) => ({ + container: { + width: '100%', + height: '100%', + backgroundColor: theme.colors.background, + padding: 20, + gap: 1, + color: theme.colors.text, + borderLeftWidth: 1, + borderLeftColor: theme.colors.sidebarDivider, + }, + tabContainer: { + flexDirection: 'row', + marginBottom: 16, + }, + tab: { + flex: 1, + paddingVertical: Spacing.small, + paddingHorizontal: Spacing.medium, + backgroundColor: theme.colors.surface, + borderBottomWidth: 1, + borderColor: theme.colors.divider, + }, + activeTab: { + backgroundColor: theme.colors.divider, + }, + tabText: { + fontSize: 18, + color: theme.colors.text, + }, + content: { + color: theme.colors.text, + padding: Spacing.medium, + }, + itemContainer: { + maxWidth: 170, + }, + itemText: { + color: theme.colors.text, + marginBottom: Spacing.small, + }, + itemSeparator: { + width: Spacing.medium, + }, +})); diff --git a/apps/mobile/src/components/index.ts b/apps/mobile/src/components/index.ts index b8174134..e6909bda 100644 --- a/apps/mobile/src/components/index.ts +++ b/apps/mobile/src/components/index.ts @@ -11,6 +11,9 @@ export {Modal} from './Modal'; export {Modalize} from './Modalize'; export {Picker} from './Picker'; export {PickerContainer} from './PickerContainer'; +export {Chat} from './PrivateMessages/Chat'; +export {Conversation} from './PrivateMessages/Conversation'; +export {FormPrivateMessage} from './PrivateMessages/FormPrivateMessage'; export {InputAccessoryView} from './Skeleton/InputAccessoryView'; export {KeyboardFixedView} from './Skeleton/KeyboardFixedView'; export {RootScreenContainer} from './Skeleton/RootScreenContainer'; @@ -18,6 +21,3 @@ export {SquareInput} from './SquareInput'; export {Text} from './Text'; export {TextButton} from './TextButton'; export {Toast} from './Toast'; -export {Conversation} from './PrivateMessages/Conversation' -export {Chat} from './PrivateMessages/Chat' -export {FormPrivateMessage} from './PrivateMessages/FormPrivateMessage' diff --git a/apps/mobile/src/components/search/index.tsx b/apps/mobile/src/components/search/index.tsx index b3917127..f3df4566 100644 --- a/apps/mobile/src/components/search/index.tsx +++ b/apps/mobile/src/components/search/index.tsx @@ -1,5 +1,5 @@ import {NDKKind} from '@nostr-dev-kit/ndk'; -import useSearch from 'afk_nostr_sdk/src/hooks/search/useSearch'; // Import useSearch +// Import useSearch import React, {useEffect, useState} from 'react'; import {Pressable, Text, TextInput, View} from 'react-native'; import Svg, {Path} from 'react-native-svg'; @@ -14,9 +14,8 @@ interface ISearchComponent { kinds?: NDKKind[]; setKinds?: (kinds: NDKKind[]) => void; contactList?: string[]; - setSortBy?: (sort:string) => void; - sortBy?:string; - + setSortBy?: (sort: string) => void; + sortBy?: string; } const SearchComponent: React.FC = ({ @@ -26,16 +25,16 @@ const SearchComponent: React.FC = ({ setKinds = () => {}, contactList, sortBy, - setSortBy + setSortBy, }) => { const styles = useStyles(stylesheet); const [debouncedQuery, setDebouncedQuery] = useState(searchQuery); const [isOpenFilter, setIsOpenFilter] = useState(false); - const [activeSortBy, setActiveSortBy] = useState(sortBy ?? "trending"); + const [activeSortBy, setActiveSortBy] = useState(sortBy ?? 'trending'); const handleSortChange = (sortBy: string) => { setActiveSortBy(sortBy); - setSortBy + setSortBy; }; useEffect(() => { diff --git a/apps/mobile/src/components/search/styles.ts b/apps/mobile/src/components/search/styles.ts index 3175c35e..2ba7d559 100644 --- a/apps/mobile/src/components/search/styles.ts +++ b/apps/mobile/src/components/search/styles.ts @@ -5,19 +5,19 @@ export default ThemedStyleSheet((theme) => ({ flexDirection: 'row', alignItems: 'center', // backgroundColor: '#f0f0f0', - background: theme.colors.background, + backgroundColor: theme.colors.background, color: theme.colors.text, borderRadius: 20, marginHorizontal: 10, paddingHorizontal: 10, - paddingVertical: 5, + paddingVertical: 20, }, iconContainer: { marginRight: 10, }, input: { flex: 1, - background: theme.colors.background, + backgroundColor: theme.colors.background, borderColor: theme.colors.background, color: theme.colors.text, diff --git a/apps/mobile/src/hooks/launchpad/useSellCoin.ts b/apps/mobile/src/hooks/launchpad/useSellCoin.ts index 994d6575..b02d9e24 100644 --- a/apps/mobile/src/hooks/launchpad/useSellCoin.ts +++ b/apps/mobile/src/hooks/launchpad/useSellCoin.ts @@ -1,6 +1,6 @@ import {useAccount, useNetwork, useProvider} from '@starknet-react/core'; import {LAUNCHPAD_ADDRESS} from 'common'; -import {AccountInterface, CallData, constants, RpcProvider, uint256} from 'starknet'; +import {AccountInterface, CallData, constants, RpcProvider} from 'starknet'; import {TokenQuoteBuyKeys} from '../../types/keys'; import {formatFloatToUint256} from '../../utils/format'; @@ -20,7 +20,8 @@ export const useSellCoin = () => { contractAddress?: string, ) => { if (!account) return; - const addressContract = contractAddress ?? LAUNCHPAD_ADDRESS[constants.StarknetChainId.SN_SEPOLIA]; + const addressContract = + contractAddress ?? LAUNCHPAD_ADDRESS[constants.StarknetChainId.SN_SEPOLIA]; // console.log('addressContract', addressContract); // let launchpad_contract = await prepareAndConnectContract( // provider, @@ -28,7 +29,7 @@ export const useSellCoin = () => { // account // ); - let amountUint256 = formatFloatToUint256(amount); + const amountUint256 = formatFloatToUint256(amount); // amountUint256 = uint256.bnToUint256(BigInt('0x' + amount)); const sellKeysParams = { diff --git a/apps/mobile/src/hooks/useIsDesktop.ts b/apps/mobile/src/hooks/useIsDesktop.ts index e131eaa5..c31bb880 100644 --- a/apps/mobile/src/hooks/useIsDesktop.ts +++ b/apps/mobile/src/hooks/useIsDesktop.ts @@ -1,14 +1,12 @@ -import { useMemo } from "react"; -import { useWindowDimensions } from "./useWindowDimensions"; +import {useMemo} from 'react'; +import {useWindowDimensions} from './useWindowDimensions'; export const useIsDesktop = () => { + const dimensions = useWindowDimensions(); + const isDesktop = useMemo(() => { + return dimensions.width >= 1024; + }, [dimensions]); // Adjust based on your breakpoint for desktop - const dimensions = useWindowDimensions(); - const isDesktop = useMemo(() => { - return dimensions.width >= 1024; - }, [dimensions]); // Adjust based on your breakpoint for desktop - - return isDesktop - -} + return isDesktop; +}; diff --git a/apps/mobile/src/hooks/useWebln.ts b/apps/mobile/src/hooks/useWebln.ts new file mode 100644 index 00000000..27165fd8 --- /dev/null +++ b/apps/mobile/src/hooks/useWebln.ts @@ -0,0 +1,47 @@ +export const useWebln = () => { + const handleWebln = async () => { + if (window.webln) { + try { + await window.webln.enable(); + const invoice = await window.webln.makeInvoice({ + amount: 1000, + defaultMemo: 'React Native Zap', + }); + } catch (error) {} + } else { + } + }; + + const handleGetBalance = async () => { + if (window.webln) { + try { + await window.webln.enable(); + // let connected = await window.webln.isEnabled() + // if(!connected) { + + // } + // const invoice = await window?.webln?.getBalance(); + } catch (error) {} + } else { + } + }; + + const handleMakeInvoice = async (amount: number, memo?: string) => { + if (window.webln) { + try { + await window.webln.enable(); + const invoice = await window.webln.makeInvoice({ + amount: amount ?? 1000, + defaultMemo: memo ?? 'React Native Zap', + }); + } catch (error) {} + } else { + } + }; + + return { + handleGetBalance, + handleMakeInvoice, + handleWebln, + }; +}; diff --git a/apps/mobile/src/modules/DirectMessages/index.tsx b/apps/mobile/src/modules/DirectMessages/index.tsx index a019600f..05f5aedb 100644 --- a/apps/mobile/src/modules/DirectMessages/index.tsx +++ b/apps/mobile/src/modules/DirectMessages/index.tsx @@ -1,22 +1,22 @@ -import React, { useEffect, useState } from 'react'; -import { FlatList, View } from 'react-native'; -import { useStyles } from '../../hooks'; -import { ConversationType } from '../../types/messages'; -import { conversationsData } from '../../utils/dummyData'; +import {useMyGiftWrapMessages, useMyMessagesSent} from 'afk_nostr_sdk'; +import React, {useEffect, useState} from 'react'; +import {FlatList, View} from 'react-native'; + +import {Conversation as ConversationPreview} from '../../components'; +import {Chat} from '../../components/PrivateMessages/Chat'; +import {FormPrivateMessage} from '../../components/PrivateMessages/FormPrivateMessage'; +import {useStyles} from '../../hooks'; +import {ConversationType} from '../../types/messages'; +import {conversationsData} from '../../utils/dummyData'; import stylesheet from './styles'; -import { Chat } from '../../components/PrivateMessages/Chat'; -import { Conversation as ConversationPreview, Input } from '../../components'; -import { FormPrivateMessage } from '../../components/PrivateMessages/FormPrivateMessage'; -import { useMyGiftWrapMessages, useMyMessagesSent } from 'afk_nostr_sdk'; export const DirectMessages: React.FC = () => { - const styles = useStyles(stylesheet); const [conversations, setConversations] = useState([]); const [selectedConversation, setSelectedConversation] = useState(null); - const giftMessages = useMyGiftWrapMessages() - const messagesSent = useMyMessagesSent() + const giftMessages = useMyGiftWrapMessages(); + const messagesSent = useMyMessagesSent(); useEffect(() => { // Fetch the list of messages // const { conversationsData } = useGetMessages(); @@ -27,28 +27,30 @@ export const DirectMessages: React.FC = () => { setSelectedConversation(null); }; - console.log("giftMessages", giftMessages?.data?.pages) - console.log("messagesSent", messagesSent?.data?.pages) + console.log('giftMessages', giftMessages?.data?.pages); + console.log('messagesSent', messagesSent?.data?.pages); return ( <> - - - {selectedConversation ? - : ( - - conversation.id} - renderItem={({ item }) => ( - setSelectedConversation(item)} /> - )} - ItemSeparatorComponent={() => } - /> - - )} + {selectedConversation ? ( + + ) : ( + + conversation.id} + renderItem={({item}) => ( + setSelectedConversation(item)} + /> + )} + ItemSeparatorComponent={() => } + /> + + )} {/* { ItemSeparatorComponent={() => } /> */} - ); -}; \ No newline at end of file +}; diff --git a/apps/mobile/src/modules/DirectMessages/styles.ts b/apps/mobile/src/modules/DirectMessages/styles.ts index 5535fb41..992c9a4d 100644 --- a/apps/mobile/src/modules/DirectMessages/styles.ts +++ b/apps/mobile/src/modules/DirectMessages/styles.ts @@ -1,12 +1,12 @@ import {ThemedStyleSheet} from '../../styles'; export default ThemedStyleSheet((theme) => ({ - container: { - flex: 1, - backgroundColor: theme.colors.background, - }, - separator: { - height: 1, - backgroundColor: theme.colors.divider, - }, + container: { + flex: 1, + backgroundColor: theme.colors.background, + }, + separator: { + height: 1, + backgroundColor: theme.colors.divider, + }, })); diff --git a/apps/mobile/src/modules/Group/addGroup/AddGroup.tsx b/apps/mobile/src/modules/Group/addGroup/AddGroup.tsx index 03b71221..04acb9a7 100644 --- a/apps/mobile/src/modules/Group/addGroup/AddGroup.tsx +++ b/apps/mobile/src/modules/Group/addGroup/AddGroup.tsx @@ -1,3 +1,13 @@ +import {useQueryClient} from '@tanstack/react-query'; +import { + AdminGroupPermission, + useAddMember, + useAddPermissions, + useAuth, + useCreateGroup, + useGetGroupPermission, +} from 'afk_nostr_sdk'; +import {Formik} from 'formik'; import {useState} from 'react'; import {Text, View} from 'react-native'; import {SafeAreaView} from 'react-native-safe-area-context'; @@ -5,45 +15,135 @@ import {SafeAreaView} from 'react-native-safe-area-context'; import {Picker} from '../../../components'; import {Button, Input} from '../../../components'; import {useStyles} from '../../../hooks'; +import {useToast} from '../../../hooks/modals'; import stylesheet from './styles'; export const CreateGroup: React.FC = () => { const styles = useStyles(stylesheet); - const [groupName, setGroupName] = useState(''); - const [groupType, setGroupType] = useState(''); + const [groupId, setGroupId] = useState(); + const {data: permissionData} = useGetGroupPermission(groupId as any); - const handleSubmit = () => { - // Here you would typically handle the form submission, - // e.g., sending the data to an API - console.log('Submitted:', {groupName, groupType}); + const {publicKey: pubkey} = useAuth(); + const {showToast} = useToast(); + const queryClient = useQueryClient(); + const {mutate} = useCreateGroup(); + const {mutate: addMember} = useAddMember(); + const {mutate: addPermission} = useAddPermissions(); + + const initialValues = { + groupName: '', + access: 'private', }; return ( - - - Create a New Group - Add a new group and set its privacy level. - - - - setGroupType(itemValue)} - label="" - > - - - - - - + { + mutate( + { + groupType: values.access as any, + groupName: values.groupName, + }, + { + onSuccess(data) { + setGroupId(groupId); + // After Group Creation, first add permissions for the admin + addPermission( + { + groupId: data.id, + pubkey, + permissionName: [ + AdminGroupPermission.AddMember, + AdminGroupPermission.AddPermission, + AdminGroupPermission.DeleteEvent, + AdminGroupPermission.DeleteGroup, + AdminGroupPermission.EditGroupStatus, + AdminGroupPermission.EditMetadata, + AdminGroupPermission.RemovePermission, + AdminGroupPermission.RemoveUser, + AdminGroupPermission.ViewAccess, + ], + }, + { + onSuccess() { + // After adding permissions, add the admin as a member + addMember( + { + groupId: data.id, + pubkey, + permissionData: permissionData as any, + }, + { + onSuccess() { + showToast({type: 'success', title: 'Group Created successfully'}); + queryClient.invalidateQueries({ + queryKey: ['getAllGroups'], + }); + queryClient.invalidateQueries({ + queryKey: ['getAllGroupMember'], + }); + queryClient.invalidateQueries({ + queryKey: ['getPermissionsByUserConnected', data.id], + }); + }, + onError() { + showToast({ + type: 'error', + title: + 'Error! Admin could not be added as a member. Please try again later.', + }); + }, + }, + ); + }, + onError() { + showToast({ + type: 'error', + title: 'Error! Admin permissions could not be set. Please try again later.', + }); + }, + }, + ); + }, + onError() { + showToast({ + type: 'error', + title: 'Error! Group could not be created. Please try again later.', + }); + }, + }, + ); + }} + > + {({handleChange, handleBlur, setFieldValue, handleSubmit, values}) => ( + + + Create a New Group + Add a new group and set its privacy level. + + + + setFieldValue('access', itemValue)} + label="" + > + + + + + + + )} + ); }; diff --git a/apps/mobile/src/modules/Group/all/AllGroup.tsx b/apps/mobile/src/modules/Group/all/AllGroup.tsx index 83f0cb0c..1ce8c9ee 100644 --- a/apps/mobile/src/modules/Group/all/AllGroup.tsx +++ b/apps/mobile/src/modules/Group/all/AllGroup.tsx @@ -1,45 +1,111 @@ import {useNavigation} from '@react-navigation/native'; -import {FlatList, SafeAreaView, Text, TouchableOpacity, View} from 'react-native'; +import {useQueryClient} from '@tanstack/react-query'; +import { + AdminGroupPermission, + useAddMember, + useAddPermissions, + useAuth, + useGetGroupList, +} from 'afk_nostr_sdk'; +import { + ActivityIndicator, + FlatList, + RefreshControl, + SafeAreaView, + Text, + TouchableOpacity, + View, +} from 'react-native'; -import {GlobeIcon, PadlockIcon, SlantedArrowIcon} from '../../../assets/icons'; +import {PadlockIcon, SlantedArrowIcon} from '../../../assets/icons'; import {useStyles} from '../../../hooks'; import {MainStackNavigationProps} from '../../../types'; import stylesheet from './styles'; -// Mock data for the groups -const groups = [ - {id: '1', name: 'Book Club', type: 'public'}, - {id: '2', name: 'Family', type: 'private'}, - {id: '3', name: 'Work Team', type: 'private'}, - {id: '4', name: 'Hiking Enthusiasts', type: 'public'}, - {id: '5', name: 'Local Community', type: 'public'}, -]; - export default function AllGroupListComponent() { + const {data, isPending, isFetching, refetch, fetchNextPage} = useGetGroupList({}); + const {mutate: addMember} = useAddMember(); + const queryClient = useQueryClient(); + const {mutate: addPermission} = useAddPermissions(); + const {publicKey} = useAuth(); const styles = useStyles(stylesheet); const navigation = useNavigation(); return ( - My Groups + All Groups + + {isPending ? ( + + ) : ( + data?.pages?.length == 0 && + )} ( + data={data.pages.flat()} + renderItem={({item}: any) => ( navigation.navigate('GroupChat', {groupId: item.id})} + onPress={() => { + // Check if the group is pubic, if yes add the use to the group. + if ( + (item?.tags.find((tag: any) => tag[0] === 'access')?.[1] || 'public') === + 'public' && + publicKey !== item?.tags.find((tag: any) => tag[0] === 'p')?.[1] + ) { + // Add the member to the group + addMember( + { + groupId: item.originalGroupId, + pubkey: publicKey, + }, + { + onSuccess() { + // After successfully adding external member by pubkey, give them default view access. + addPermission( + { + groupId: item.originalGroupId, + pubkey: publicKey, + permissionName: [AdminGroupPermission.ViewAccess], + }, + { + onSuccess() { + queryClient.invalidateQueries({queryKey: ['getAllGroupMember']}); + queryClient.invalidateQueries({ + queryKey: ['getPermissionsByUserConnected', item.originalGroupId], + }); + + navigation.navigate('GroupChat', { + groupId: item.originalGroupId, + groupName: item.content, + groupAccess: + item?.tags.find((tag: any) => tag[0] === 'access')?.[1] || 'public', + }); + }, + onError() { + console.error('Something went wrong joining this group'); + }, + }, + ); + }, + }, + ); + } + navigation.navigate('GroupChat', { + groupId: item.originalGroupId, + groupName: item.content, + groupAccess: item?.tags.find((tag: any) => tag[0] === 'access')?.[1] || 'public', + }); + }} style={styles.groupItem} > - {item.name} + {item.content || 'No Name'} - {item.type === 'private' ? ( - - ) : ( - - )} - {item.type} + + + {item?.tags.find((tag: any) => tag[0] === 'access')?.[1]} + @@ -47,8 +113,10 @@ export default function AllGroupListComponent() { )} - keyExtractor={(item) => item.id} + keyExtractor={(item: any) => item.id} contentContainerStyle={styles.listContent} + refreshControl={ refetch()} />} + onEndReached={() => fetchNextPage()} /> ); diff --git a/apps/mobile/src/modules/Group/all/styles.ts b/apps/mobile/src/modules/Group/all/styles.ts index 590c9fb7..92b2249d 100644 --- a/apps/mobile/src/modules/Group/all/styles.ts +++ b/apps/mobile/src/modules/Group/all/styles.ts @@ -43,9 +43,9 @@ export default ThemedStyleSheet((theme) => ({ }, groupName: { color: theme.colors.text, - fontSize: 18, + fontSize: 16, fontWeight: '600', - marginBottom: 4, + marginBottom: 7, }, groupType: { color: theme.colors.text, diff --git a/apps/mobile/src/modules/Group/groupDetail/GroupChatDetail.tsx b/apps/mobile/src/modules/Group/groupDetail/GroupChatDetail.tsx index 2c0710cb..139591e5 100644 --- a/apps/mobile/src/modules/Group/groupDetail/GroupChatDetail.tsx +++ b/apps/mobile/src/modules/Group/groupDetail/GroupChatDetail.tsx @@ -1,67 +1,188 @@ +import {useQueryClient} from '@tanstack/react-query'; +import { + AdminGroupPermission, + useAuth, + useDeleteGroup, + useGetGroupMemberList, + useGetGroupMetadata, + useGetGroupPermission, +} from 'afk_nostr_sdk'; import React, {useRef, useState} from 'react'; -import {FlatList, SafeAreaView, Text, TouchableOpacity, View} from 'react-native'; +import {FlatList, Pressable, SafeAreaView, TouchableOpacity, View} from 'react-native'; -import {BackIcon} from '../../../assets/icons'; -import {IconButton, Modalize} from '../../../components'; -import {useStyles} from '../../../hooks'; +import {AddPostIcon, BackIcon, EditIcon, TrashIcon, UserPlusIcon} from '../../../assets/icons'; +import {IconButton, Modalize, Text} from '../../../components'; +import {useStyles, useTheme} from '../../../hooks'; +import {useToast} from '../../../hooks/modals'; import {GroupChatDetailScreenProps} from '../../../types'; +import AddMemberView from '../memberAction/addMember'; +import {EditGroup} from '../memberAction/editGroup'; import GroupAdminActions from '../memberAction/groupAction'; import stylesheet from './styles'; -const data = [ - {id: '1', name: 'Alice Johnson', role: 'Admin'}, - {id: '2', name: 'Bob Smith', role: 'Member'}, - {id: '3', name: 'Charlie Brown', role: 'Member'}, - {id: '4', name: 'Diana Prince', role: 'Member'}, - {id: '5', name: 'Ethan Hunt', role: 'Member'}, -]; - const GroupChatDetail: React.FC = ({navigation, route}) => { + const theme = useTheme(); + const {publicKey: pubKey} = useAuth(); + const queryClient = useQueryClient(); + const {showToast} = useToast(); + const {data: permissionData} = useGetGroupPermission(route.params.groupId); + const {data: groupMetaData} = useGetGroupMetadata({groupId: route.params.groupId, pubKey}); + const memberListData = useGetGroupMemberList({ + groupId: route.params.groupId, + }); + + const {mutate} = useDeleteGroup(); const modalizeRef = useRef(null); + const addMemberModalizeRef = useRef(null); + const editGroupModalizeRef = useRef(null); + const menuModalizeRef = useRef(null); + const styles = useStyles(stylesheet); + const [selectedMember, setSelectedMember] = useState(); - const onOpen = () => { + const onOpen = (selected: any) => { modalizeRef.current?.open(); + setSelectedMember(selected); + }; + + const onOpenAddMember = () => { + addMemberModalizeRef.current?.open(); + menuModalizeRef.current?.close(); + }; + const onOpenEditGroup = () => { + editGroupModalizeRef.current?.open(); + menuModalizeRef.current?.close(); + }; + const onOpenViewRequest = () => { + navigation.navigate('GroupChatMemberRequest', { + groupId: route.params.groupId, + groupName: route.params.groupName, + groupAccess: route.params.groupAccess, + }); + menuModalizeRef.current?.close(); + }; + + const onOpenMenu = (selected: any) => { + setSelectedMember(selected); + menuModalizeRef.current?.open(); }; - const styles = useStyles(stylesheet); - const [groupName] = useState('Project Team'); - const [members] = useState(data); return ( <> - + modalizeRef.current?.close()} + permissionData={permissionData as any} + /> - - navigation.navigate('GroupChat', {groupId: route.params.groupId})} - > - - - - {groupName} - {members.length} members + + editGroupModalizeRef.current?.close()} + groupId={route.params.groupId ? route.params.groupId : ''} + /> + + + addMemberModalizeRef.current?.close()} + groupId={route.params.groupId ? route.params.groupId : ''} + /> + + + + { + mutate( + { + groupId: route.params.groupId, + }, + { + onSuccess: () => { + showToast({type: 'success', title: 'Group Deleted Successfully'}); + queryClient.invalidateQueries({queryKey: ['getAllGroups']}); + menuModalizeRef.current?.close(); + }, + onError: () => { + showToast({ + type: 'error', + title: 'Error! Couldnt Delete Group. Please try again later.', + }); + }, + }, + ); + }} + onOpenAddMember={() => onOpenAddMember()} + /> + + + + + + navigation.navigate('GroupChat', { + groupId: route.params.groupId, + groupName: route.params.groupName, + groupAccess: route.params.groupAccess, + }) + } + > + + + + {route.params.groupName} + + {memberListData.data.pages.flat().length} members + + onOpen()} />} - keyExtractor={(item) => item.id} + data={memberListData.data.pages.flat()} + renderItem={({item}: any) => onOpen(item)} />} + keyExtractor={(item: any) => item.id} contentContainerStyle={styles.memberList} /> + + + ); }; const MemberCard = ({item, handleOpen}: {item: any; handleOpen: () => void}) => { + const {publicKey} = useAuth(); + const pub = item?.tags.find((tag: any) => tag[0] === 'p')?.[1]; const styles = useStyles(stylesheet); return ( - {item.name} + {publicKey === pub ? ( + + {pub} + + ) : ( + + {pub} + + )} + {item.role} @@ -74,5 +195,56 @@ const MemberCard = ({item, handleOpen}: {item: any; handleOpen: () => void}) => ); }; +const MenuBubble = ({ + onOpenAddMember, + onDeleteGroup, + onEditGroup, + onOpenViewRequest, + permissionData, +}: { + onOpenAddMember: () => void; + onDeleteGroup: () => void; + onEditGroup: () => void; + onOpenViewRequest: () => void; + permissionData: AdminGroupPermission[]; + groupId: string; +}) => { + const styles = useStyles(stylesheet); + const theme = useTheme(); + + return ( + + Member Actions + + {permissionData && permissionData.includes(AdminGroupPermission.AddMember) && ( + + + Add Member + + )} + + {permissionData && permissionData.includes(AdminGroupPermission.EditMetadata) && ( + + + Edit Group + + )} + + {permissionData && permissionData.includes(AdminGroupPermission.AddMember) && ( + onOpenViewRequest()}> + + View Request + + )} + + {permissionData && permissionData.includes(AdminGroupPermission.DeleteGroup) && ( + + + Delete Group + + )} + + ); +}; export default GroupChatDetail; diff --git a/apps/mobile/src/modules/Group/groupDetail/styles.ts b/apps/mobile/src/modules/Group/groupDetail/styles.ts index 2bf5a848..c91d8676 100644 --- a/apps/mobile/src/modules/Group/groupDetail/styles.ts +++ b/apps/mobile/src/modules/Group/groupDetail/styles.ts @@ -47,7 +47,7 @@ export default ThemedStyleSheet((theme) => ({ flex: 1, }, memberName: { - fontSize: 16, + fontSize: 14, fontWeight: '500', color: theme.colors.text, }, @@ -63,4 +63,35 @@ export default ThemedStyleSheet((theme) => ({ backgroundColor: theme.colors.buttonDisabledBackground, padding: Spacing.small, }, + addMemberButton: { + position: 'absolute', + bottom: Spacing.large, + right: Spacing.pagePadding, + }, + + //Menu + menuContainer: { + padding: 16, + }, + title: { + fontSize: 18, + fontWeight: 'bold', + marginBottom: 16, + color: theme.colors.text, + }, + actionButton: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + borderBottomWidth: 1, + borderBottomColor: theme.colors.divider, + }, + actionText: { + marginLeft: 16, + fontSize: 16, + color: theme.colors.text, + }, + deleteText: { + color: theme.colors.red, + }, })); diff --git a/apps/mobile/src/modules/Group/memberAction/ViewRequest.tsx b/apps/mobile/src/modules/Group/memberAction/ViewRequest.tsx new file mode 100644 index 00000000..a66356b9 --- /dev/null +++ b/apps/mobile/src/modules/Group/memberAction/ViewRequest.tsx @@ -0,0 +1,206 @@ +import {useQueryClient} from '@tanstack/react-query'; +import { + AdminGroupPermission, + useAddMember, + useAddPermissions, + useGetGroupMemberList, + useGetGroupPermission, + useGetGroupRequest, +} from 'afk_nostr_sdk'; +import React, {useRef, useState} from 'react'; +import {FlatList, SafeAreaView, TouchableOpacity, View} from 'react-native'; + +import {BackIcon, UserPlusIcon} from '../../../assets/icons'; +import {IconButton, Modalize, Text} from '../../../components'; +import {useStyles, useTheme} from '../../../hooks'; +import {useToast} from '../../../hooks/modals'; +import {GroupChatMemberRequestScreenProps} from '../../../types'; +import stylesheet from '../groupDetail/styles'; + +const GroupChatGroupRequest: React.FC = ({ + navigation, + route, +}) => { + const queryClient = useQueryClient(); + const [selected, setSelected] = useState(); + const {showToast} = useToast(); + const {mutate} = useAddMember(); + const {mutate: addPermission} = useAddPermissions(); + const {data: permissionData} = useGetGroupPermission(route.params.groupId as any); + const viewGroupRequest = useGetGroupRequest({ + groupId: route.params.groupId, + }); + const groupMembers = useGetGroupMemberList({ + groupId: route.params.groupId, + }); + + const modalizeRef = useRef(null); + + const styles = useStyles(stylesheet); + + //Todo: you can check from the permission instead maybe? + const checkIfMemberExists = () => { + return groupMembers?.data.pages.some((page: any) => + page.some((event: any) => + event.tags.some( + (tag: any) => + tag[0] === 'p' && tag[1] === selected?.tags.find((tag: any) => tag[0] === 'p')?.[1], + ), + ), + ); + }; + + return ( + <> + + + console.log('')} + onAcceptRequest={() => { + //Check if the pubKey that wants to be added exist + if (checkIfMemberExists()) { + showToast({ + type: 'error', + title: 'Error! This public key is already a member of the group.', + }); + } else { + mutate( + { + pubkey: selected?.tags.find((tag: any) => tag[0] === 'p')?.[1], + groupId: route.params.groupId, + permissionData: permissionData as any, + }, + { + onSuccess() { + // After successfully adding external member by pubkey, give them default view access. + addPermission( + { + groupId: route.params.groupId, + pubkey: selected?.tags.find((tag: any) => tag[0] === 'p')?.[1], + permissionName: [AdminGroupPermission.ViewAccess], + }, + { + onSuccess() { + showToast({ + type: 'success', + title: 'Member added and permissions set successfully', + }); + queryClient.invalidateQueries({queryKey: ['getAllGroupMember']}); + queryClient.invalidateQueries({ + queryKey: ['getPermissionsByUserConnected', route.params.groupId], + }); + modalizeRef.current?.close(); + }, + onError() { + showToast({ + type: 'error', + title: + 'Member added but permissions could not be set. Please set permissions manually.', + }); + queryClient.invalidateQueries({queryKey: ['getAllGroupMember']}); + }, + }, + ); + }, + onError() { + showToast({ + type: 'error', + title: 'Error! Member could not be added. Please try again later.', + }); + }, + }, + ); + } + }} + /> + + + + + + navigation.navigate('GroupChatDetail', { + groupId: route.params.groupId, + groupName: route.params.groupName, + groupAccess: route.params.groupAccess, + }) + } + > + + + + {route.params.groupName} + + {viewGroupRequest.data.pages.flat().length} Request + + + + + + ( + { + modalizeRef.current?.open(); + setSelected(item); + }} + /> + )} + keyExtractor={(item: any) => item.id} + contentContainerStyle={styles.memberList} + /> + + + ); +}; + +const MemberCard = ({item, handleOpen}: {item: any; handleOpen: () => void}) => { + const pub = item?.tags.find((tag: any) => tag[0] === 'p')?.[1]; + const styles = useStyles(stylesheet); + return ( + + + + {pub} + + {item.role} + + + + + ); +}; +const MenuBubble = ({ + onAcceptRequest, + onDeclineRequest, +}: { + onDeclineRequest: () => void; + onAcceptRequest: () => void; +}) => { + const styles = useStyles(stylesheet); + const theme = useTheme(); + + return ( + + Request Actions + + + Accept Request + + {/* + + Decline Group + */} + + ); +}; + +export default GroupChatGroupRequest; diff --git a/apps/mobile/src/modules/Group/memberAction/addMember.tsx b/apps/mobile/src/modules/Group/memberAction/addMember.tsx new file mode 100644 index 00000000..ca011677 --- /dev/null +++ b/apps/mobile/src/modules/Group/memberAction/addMember.tsx @@ -0,0 +1,121 @@ +import {useQueryClient} from '@tanstack/react-query'; +import { + AdminGroupPermission, + useAddMember, + useAddPermissions, + useGetGroupMemberList, +} from 'afk_nostr_sdk'; +import {Formik} from 'formik'; +import {Text, View} from 'react-native'; + +import {Button, Input} from '../../../components'; +import {useStyles} from '../../../hooks'; +import {useToast} from '../../../hooks/modals'; +import stylesheet from './styles'; + +export default function AddMemberView({ + groupId, + handleClose, +}: { + groupId: string; + handleClose: () => void; +}) { + const groupMembers = useGetGroupMemberList({ + groupId, + }); + const {mutate: addMember} = useAddMember(); + const {mutate: addPermission} = useAddPermissions(); + + const queryClient = useQueryClient(); + const {showToast} = useToast(); + const styles = useStyles(stylesheet); + + const initialValues = { + pubKey: '', + groupId, + }; + + const checkIfMemberExists = (pubKey: string) => { + return groupMembers?.data.pages.some((page: any) => + page.some((event: any) => event.tags.some((tag: any) => tag[0] === 'p' && tag[1] === pubKey)), + ); + }; + + return ( + + Add New Member + Enter user public key. + + { + if (checkIfMemberExists(values.pubKey)) { + showToast({ + type: 'error', + title: 'Error! This public key is already a member of the group.', + }); + } else { + addMember( + { + pubkey: values.pubKey, + groupId, + }, + { + onSuccess() { + // After successfully adding external member by pubkey, give them default view access. + addPermission( + { + groupId, + pubkey: values.pubKey, + permissionName: [AdminGroupPermission.ViewAccess], + }, + { + onSuccess() { + showToast({ + type: 'success', + title: 'Member added and permissions set successfully', + }); + queryClient.invalidateQueries({queryKey: ['getAllGroupMember']}); + queryClient.invalidateQueries({ + queryKey: ['getPermissionsByUserConnected', groupId], + }); + handleClose(); + }, + onError() { + showToast({ + type: 'error', + title: + 'Member added but permissions could not be set. Please set permissions manually.', + }); + queryClient.invalidateQueries({queryKey: ['getAllGroupMember']}); + handleClose(); + }, + }, + ); + }, + onError() { + showToast({ + type: 'error', + title: 'Error! Member could not be added. Please try again later.', + }); + }, + }, + ); + } + }} + > + {({handleChange, handleBlur, handleSubmit, values}) => ( + + + + + )} + + + ); +} diff --git a/apps/mobile/src/modules/Group/memberAction/editGroup.tsx b/apps/mobile/src/modules/Group/memberAction/editGroup.tsx new file mode 100644 index 00000000..7747a8cf --- /dev/null +++ b/apps/mobile/src/modules/Group/memberAction/editGroup.tsx @@ -0,0 +1,102 @@ +import {NDKEvent} from '@nostr-dev-kit/ndk'; +import {useQueryClient} from '@tanstack/react-query'; +import {AdminGroupPermission, useGroupEditMetadata} from 'afk_nostr_sdk'; +import {Formik} from 'formik'; +import {Text, View} from 'react-native'; +import {SafeAreaView} from 'react-native-safe-area-context'; + +import {Button, Input, Picker, SquareInput} from '../../../components'; +import {useStyles} from '../../../hooks'; +import {useToast} from '../../../hooks/modals'; +import stylesheet from '../addGroup/styles'; + +export const EditGroup = ({ + groupId, + handleClose, +}: { + groupId: string; + handleClose: () => void; + metaData: NDKEvent; + permissionData: AdminGroupPermission[]; +}) => { + const styles = useStyles(stylesheet); + const {showToast} = useToast(); + const queryClient = useQueryClient(); + const {mutate} = useGroupEditMetadata(); + + const initialValues = { + name: '', + about: '', + access: 'private', + }; + + return ( + + { + mutate( + { + groupId, + + meta: { + name: values.name, + about: values.about, + access: values.access, + }, + }, + { + onSuccess() { + showToast({type: 'success', title: 'Group Edited successfully'}); + queryClient.invalidateQueries({queryKey: ['getAllGroups']}); + handleClose(); + }, + onError() { + showToast({ + type: 'error', + title: 'Error! Group could not be edited. Please try again later.', + }); + }, + }, + ); + }} + > + {({handleChange, handleBlur, handleSubmit, setFieldValue, values}) => ( + + + Edit Group + + + + setFieldValue('access', itemValue)} + label="" + > + + + + + + + + )} + + + ); +}; diff --git a/apps/mobile/src/modules/Group/memberAction/groupAction.tsx b/apps/mobile/src/modules/Group/memberAction/groupAction.tsx index e978374a..ee7e5675 100644 --- a/apps/mobile/src/modules/Group/memberAction/groupAction.tsx +++ b/apps/mobile/src/modules/Group/memberAction/groupAction.tsx @@ -1,69 +1,190 @@ +import {useQueryClient} from '@tanstack/react-query'; +import {AdminGroupPermission, useAddPermissions, useRemoveMember} from 'afk_nostr_sdk'; import React, {useState} from 'react'; -import {Switch, Text, TouchableOpacity, View} from 'react-native'; +import {ScrollView, Switch, Text, TouchableOpacity, View} from 'react-native'; import {CrownIcon, RemoveIcon} from '../../../assets/icons'; import {useStyles} from '../../../hooks'; +import {useToast} from '../../../hooks/modals'; import stylesheet from './styles'; -const GroupAdminActions = () => { - const [canManageMembers, setCanManageMembers] = useState(false); - const [canEditGroup, setCanEditGroup] = useState(false); +export enum AdminGroupPermissionForm { + AddMember = 'add-user', + EditMetadata = 'edit-metadata', + DeleteEvent = 'delete-event', + RemoveUser = 'remove-user', + AddPermission = 'add-permission', + RemovePermission = 'remove-permission', + EditGroupStatus = 'edit-group-status', + DeleteGroup = 'delete-group', + ViewAccess = 'ViewAccess', +} + +const GroupAdminActions = ({ + selectedMember, + handleClose, + groupId, + permissionData, +}: { + selectedMember: any; + handleClose: () => void; + groupId: string; + permissionData: AdminGroupPermission[]; +}) => { + const {mutate: removeMember} = useRemoveMember(); + const {mutate: addPermissions} = useAddPermissions(); + const queryClient = useQueryClient(); + const {showToast} = useToast(); const styles = useStyles(stylesheet); + const {mutate: addPermission} = useAddPermissions(); + const [permissions, setPermissions] = useState({ + [AdminGroupPermissionForm.AddMember]: false, + [AdminGroupPermissionForm.EditMetadata]: false, + [AdminGroupPermissionForm.DeleteEvent]: false, + [AdminGroupPermissionForm.RemoveUser]: false, + [AdminGroupPermissionForm.AddPermission]: false, + [AdminGroupPermissionForm.RemovePermission]: false, + [AdminGroupPermissionForm.EditGroupStatus]: false, + [AdminGroupPermissionForm.DeleteGroup]: false, + }); + + const memberPubKey = selectedMember?.tags.find((tag: any) => tag[0] === 'p')?.[1]; + + const togglePermission = (permission: AdminGroupPermissionForm) => { + setPermissions((prev: any) => ({ + ...prev, + [permission]: !prev[permission], + })); + }; + + const handleMakeAdmin = () => { + const activePermissions = Object.entries(permissions) + .filter(([_, value]) => value) + .map(([key]) => key); + + activePermissions.push(AdminGroupPermission.ViewAccess); + addPermissions( + { + groupId, + pubkey: memberPubKey, + permissionName: activePermissions as any, + }, + { + onSuccess: () => { + showToast({type: 'success', title: 'Permissions updated successfully'}); + queryClient.invalidateQueries({queryKey: ['getAllGroupMember']}); + queryClient.invalidateQueries({ + queryKey: ['getPermissionsByUserConnected', groupId], + }); + + handleClose(); + }, + onError: () => { + showToast({ + type: 'error', + title: 'Error! Permissions could not be updated. Please try again later.', + }); + }, + }, + ); + }; + + const handleRemoveMember = () => { + removeMember( + { + groupId, + pubkey: memberPubKey, + }, + { + onSuccess: () => { + // After removing external member by pubkey, remove their access. + addPermission( + { + groupId, + pubkey: memberPubKey, + permissionName: [], + }, + { + onSuccess() { + showToast({type: 'success', title: 'Member removed successfully'}); + queryClient.invalidateQueries({queryKey: ['getAllGroupMember']}); + queryClient.invalidateQueries({ + queryKey: ['getPermissionsByUserConnected', groupId], + }); + handleClose(); + }, + onError() { + showToast({ + type: 'error', + title: 'Error! Member could not be removed. Please try again later.', + }); + handleClose(); + }, + }, + ); + }, + onError: () => { + showToast({ + type: 'error', + title: 'Error! Member could not be removed. Please try again later.', + }); + }, + }, + ); + }; return ( - + - Make Group Admin + Admin Permissions - - Can manage members - - - - Can edit group - - - - Make Admin - + + <> + {permissionData && permissionData.includes(AdminGroupPermission.AddPermission) && ( + <> + {Object.entries(permissions).map(([permission, value]) => ( + + {permission} + togglePermission(permission as AdminGroupPermissionForm)} + trackColor={{ + false: styles.switchTrack.backgroundColor, + true: styles.switchTrackActive.backgroundColor, + }} + thumbColor={ + value + ? styles.switchThumbActive.backgroundColor + : styles.switchThumb.backgroundColor + } + /> + + ))} + + Update Permissions + + + )} + - - - - Remove from Group + {permissionData && permissionData.includes(AdminGroupPermission.RemoveUser) && ( + + + + Remove from Group + + + Remove + - - Remove - - - + )} + ); }; diff --git a/apps/mobile/src/modules/Group/memberAction/styles.ts b/apps/mobile/src/modules/Group/memberAction/styles.ts index 144d86e2..504c0b97 100644 --- a/apps/mobile/src/modules/Group/memberAction/styles.ts +++ b/apps/mobile/src/modules/Group/memberAction/styles.ts @@ -3,8 +3,8 @@ import {Spacing, ThemedStyleSheet} from '../../../styles'; export default ThemedStyleSheet((theme) => ({ container: { flex: 1, - backgroundColor: theme.colors.background, - padding: Spacing.medium, + // backgroundColor: theme.colors.background, + padding: Spacing.small, }, card: { backgroundColor: theme.colors.background, @@ -66,4 +66,16 @@ export default ThemedStyleSheet((theme) => ({ switchThumbActive: { backgroundColor: theme.colors.buttonDisabledBackground, }, + + //Add Member Styles + title: { + fontSize: 20, + fontWeight: 'bold', + color: theme.colors.text, + marginBottom: Spacing.xsmall, + }, + text: { + fontSize: 14, + color: theme.colors.text, + }, })); diff --git a/apps/mobile/src/modules/Group/message/GroupMessage.tsx b/apps/mobile/src/modules/Group/message/GroupMessage.tsx index 0b5c4c7a..d848a7f4 100644 --- a/apps/mobile/src/modules/Group/message/GroupMessage.tsx +++ b/apps/mobile/src/modules/Group/message/GroupMessage.tsx @@ -1,42 +1,119 @@ +import {useQueryClient} from '@tanstack/react-query'; +import { + AdminGroupPermission, + useAuth, + useGetGroupMemberList, + useGetGroupMessages, + useGetGroupPermission, + useJoinGroupRequest, + useProfile, + useSendGroupMessages, +} from 'afk_nostr_sdk'; import React, {useState} from 'react'; -import {FlatList, SafeAreaView, Text, TouchableOpacity, View} from 'react-native'; +import { + ActivityIndicator, + FlatList, + Modal, + Pressable, + SafeAreaView, + Text, + TouchableOpacity, + View, +} from 'react-native'; import {BackIcon, MenuIcons} from '../../../assets/icons'; -import {IconButton, Input, KeyboardFixedView} from '../../../components'; +import {Button, IconButton, Input, KeyboardFixedView} from '../../../components'; import {useStyles} from '../../../hooks'; +import {useToast} from '../../../hooks/modals'; import {GroupChatScreenProps} from '../../../types'; import stylesheet from './styles'; -const data = [ - {id: '1', text: 'Hello everyone!', sender: 'Alice'}, - {id: '2', text: 'Hi Alice, how are you?', sender: 'Bob'}, - {id: '3', text: 'Im doing great, thanks!', sender: 'Alice'}, - { - id: '4', - text: 'Whats the plan for today? Whats the plan for today Whats the plan for todayWhats the plan for today', - sender: 'Charlie', - }, - {id: '5', text: 'Whats the plan for today?', sender: 'Charlie'}, - {id: '6', text: 'Whats the plan for today?', sender: 'Charlie'}, - {id: '7', text: 'Whats the plan for today?', sender: 'Charlie'}, - {id: '8', text: 'Whats the plan for today?', sender: 'Charlie'}, - {id: '9', text: 'Whats the plan for today?', sender: 'Charlie'}, -]; - -const groupName = 'Project Team'; -const memberCount = 15; - const GroupChat: React.FC = ({navigation, route}) => { + const {publicKey} = useAuth(); + const groupId = route.params.groupId; + const memberListData = useGetGroupMemberList({groupId}); + const profile = useProfile({publicKey}); + const [menuVisible, setMenuVisible] = useState(false); + const [replyToId, setReplyToId] = useState(null); + const [replyToContent, setReplyToContent] = useState(''); + const [selectedMessageId, setSelectedMessageId] = useState(null); + const queryClient = useQueryClient(); + const {showToast} = useToast(); + const {data: messageData} = useGetGroupMessages({groupId, authors: publicKey}); + const {mutate} = useSendGroupMessages(); const styles = useStyles(stylesheet); const [message, setMessage] = useState(''); - const [messages, setMessages] = useState(data); + const {data: permissionData} = useGetGroupPermission(route.params.groupId); + + // const isMember = memberListData?.data?.pages?.flat().some((e: NDKEvent) => { + // const pubkey = e?.tags?.find((tag: string[]) => tag[0] === 'p')?.[1]; + // return pubkey === publicKey; + // }); + + const handleLongPress = (messageId: any, messageContent: string) => { + setSelectedMessageId(messageId); + setReplyToContent(messageContent); + setMenuVisible(true); + }; + + const handleReply = () => { + setReplyToId(selectedMessageId); + setMenuVisible(false); + }; + + const cancelReply = () => { + setReplyToId(null); + setReplyToContent(''); + }; const sendMessage = () => { - if (message.trim() === '') return; - setMessages([...messages, {id: Date.now().toString(), text: message, sender: 'You'}]); - setMessage(''); + if (!message) return; + mutate( + { + content: message, + groupId, + pubkey: publicKey, + name: profile.data?.nip05, + replyId: replyToId ?? (null as any), + }, + { + onSuccess() { + showToast({type: 'success', title: 'Message sent successfully'}); + queryClient.invalidateQueries({queryKey: ['getGroupMessages', groupId]}); + setMessage(''); + cancelReply(); + }, + onError() { + showToast({ + type: 'error', + title: 'Error! Comment could not be sent. Please try again later.', + }); + }, + }, + ); }; + if (memberListData.data.pages.length === 0) { + return ( + + + + ); + } + + if ( + !permissionData?.includes(AdminGroupPermission.ViewAccess) && + route.params.groupAccess === 'private' + ) { + return ( + + ); + } + return ( @@ -44,54 +121,200 @@ const GroupChat: React.FC = ({navigation, route}) => { - {groupName} - {memberCount} members + {route.params.groupName} + + {memberListData.data.pages.flat().length} members + + + {/* If you user have any other permission apart from view then show this menu*/} navigation.navigate('GroupChatDetail', {groupId: route.params.groupId})} + onPress={() => + navigation.navigate('GroupChatDetail', { + groupId: route.params.groupId, + groupName: route.params.groupName, + groupAccess: route.params.groupAccess, + }) + } style={styles.headerButton} > + } - keyExtractor={(item) => item.id} + data={messageData.pages.flat()} + renderItem={({item}: any) => } + keyExtractor={(item: any) => item.id} contentContainerStyle={styles.messageList} inverted /> + {replyToId && } + - + sendMessage()} icon="SendIcon" size={24} /> + + setMenuVisible(false)} + onReply={handleReply} + /> ); }; -// TODO: MOVE TO COMPONENT -const MessageCard = ({item}: {item: (typeof data)[0]}) => { +const NoAccessScreen = ({ + navigation, + groupName, + groupId, +}: { + navigation: any; + groupName: string; + groupId: string; +}) => { + const {showToast} = useToast(); + const queryClient = useQueryClient(); + const {mutate: joinRequest} = useJoinGroupRequest(); const styles = useStyles(stylesheet); return ( - - {item.sender} - {item.text} + + + navigation.goBack()} style={styles.headerButton}> + + + + {groupName || 'Group'} + + + + + No Access + You are not a member of this group. + + + + ); +}; + +const MessageCard = ({ + item, + handleLongPress, +}: { + item: any; + handleLongPress: (val: any, content: string) => void; +}) => { + const styles = useStyles(stylesheet); + const memberNip = item?.tags.find((tag: any) => tag[0] === 'name')?.[1]; + const replymemberNip = item?.reply + ? item?.reply.tags.find((tag: any) => tag[0] === 'name')?.[1] + : ''; + + return ( + handleLongPress(item.id, item.content)} delayLongPress={500}> + + {item.reply && {memberNip || 'Nil'}} + {item.reply && ( + + {replymemberNip || 'Nil'} + + {item.reply.content} + + + )} + {!item.reply && {memberNip || 'Nil'}} + + {item.content} + + + ); +}; + +const ReplyIndicator = ({message, onCancel}: {onCancel: () => void; message: string}) => { + const styles = useStyles(stylesheet); + return ( + + + + Replying to: {message} + + + + ✕ + ); }; +const LongPressMenu = ({ + visible, + onClose, + onReply, +}: { + visible: boolean; + onClose: () => void; + onReply: any; +}) => { + const styles = useStyles(stylesheet); + return ( + + + + + Reply Message + + + + + ); +}; + export default GroupChat; diff --git a/apps/mobile/src/modules/Group/message/styles.ts b/apps/mobile/src/modules/Group/message/styles.ts index 6b2613a9..696a5c69 100644 --- a/apps/mobile/src/modules/Group/message/styles.ts +++ b/apps/mobile/src/modules/Group/message/styles.ts @@ -41,13 +41,33 @@ export default ThemedStyleSheet((theme) => ({ paddingBottom: 10, }, messageBubble: { - maxWidth: '90%', + maxWidth: '70%', color: theme.colors.messageCardText, backgroundColor: theme.colors.messageCard, padding: 10, borderRadius: 10, marginVertical: 5, }, + replyContainer: { + backgroundColor: theme.colors.messageReplyCard, + borderRadius: 5, + borderWidth: 1, + borderColor: theme.colors.divider, + padding: 10, + marginVertical: 5, + borderLeftColor: theme.colors.blue, + borderLeftWidth: 3, + }, + replySender: { + fontWeight: 'bold', + fontSize: 12, + color: theme.colors.messageReplyCardText, + marginBottom: 2, + }, + replyContentHighlight: { + fontSize: 12, + color: theme.colors.messageReplyCardText, + }, yourMessage: { alignSelf: 'flex-end', borderWidth: 1, @@ -82,6 +102,7 @@ export default ThemedStyleSheet((theme) => ({ input: { flex: 1, width: 'auto', + paddingTop: 10, }, sendButton: { justifyContent: 'center', @@ -96,4 +117,54 @@ export default ThemedStyleSheet((theme) => ({ fontWeight: 'bold', }, //End of All Group Styling + + // Long Press Menu + modalOverlay: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: 'rgba(0, 0, 0, 0.8)', // Keeping this as a non-theme-based color for overlay + }, + menuContainer: { + backgroundColor: theme.colors.background, + borderWidth: 1, + borderColor: theme.colors.text, + borderRadius: Spacing.xsmall, + color: theme.colors.text, + padding: Spacing.small, + minWidth: '70%', + }, + menuItem: { + padding: Spacing.small, + color: theme.colors.text, + }, + + // Reply Indicator + replyIndicator: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: theme.colors.messageCard, + padding: Spacing.small, + borderRadius: Spacing.xsmall, + marginBottom: Spacing.small, + borderWidth: 1, + borderColor: theme.colors.text, + margin: Spacing.small, + }, + replyContent: { + flex: 1, + color: theme.colors.text, + }, + replyText: { + fontSize: 14, + color: theme.colors.text, + }, + cancelButton: { + marginLeft: Spacing.small, + padding: Spacing.xsmall, + }, + cancelButtonText: { + fontSize: 16, + color: theme.colors.text, + }, })); diff --git a/apps/mobile/src/modules/Layout/sidebar/index.tsx b/apps/mobile/src/modules/Layout/sidebar/index.tsx index 374a2302..2c4661c4 100644 --- a/apps/mobile/src/modules/Layout/sidebar/index.tsx +++ b/apps/mobile/src/modules/Layout/sidebar/index.tsx @@ -1,7 +1,7 @@ // import { useAuth } from '../../../store/auth'; import {useAuth, useNostrContext} from 'afk_nostr_sdk'; import React, {useEffect, useMemo} from 'react'; -import {Pressable, Text, View} from 'react-native'; +import {Pressable, Text, View, Image} from 'react-native'; import {Icon} from '../../../components/Icon'; import {useStyles, useTheme, useWindowDimensions} from '../../../hooks'; @@ -60,7 +60,10 @@ const Sidebar = ({navigation}: SidebarInterface) => { return ( - AFK + + { isDesktop && } + AFK + {/* Launchpad diff --git a/apps/mobile/src/modules/Layout/sidebar/styles.ts b/apps/mobile/src/modules/Layout/sidebar/styles.ts index 6ba2c62e..82fa0219 100644 --- a/apps/mobile/src/modules/Layout/sidebar/styles.ts +++ b/apps/mobile/src/modules/Layout/sidebar/styles.ts @@ -16,6 +16,12 @@ export default ThemedStyleSheet((theme) => ({ flexDirection: 'row', alignItems: 'center', }, + rowContainer: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 30, + }, logo: { width: 40, height: 40, diff --git a/apps/mobile/src/modules/Lightning/index.tsx b/apps/mobile/src/modules/Lightning/index.tsx index b862df22..b625929d 100644 --- a/apps/mobile/src/modules/Lightning/index.tsx +++ b/apps/mobile/src/modules/Lightning/index.tsx @@ -1,29 +1,33 @@ import '../../../applyGlobalPolyfills'; -import { init, launchModal } from '@getalby/bitcoin-connect-react'; -import { LightningAddress } from '@getalby/lightning-tools'; -import { webln } from '@getalby/sdk'; -import React from 'react'; -import { Platform, SafeAreaView, Text, TextInput, View } from 'react-native'; -import WebView from 'react-native-webview'; +import {init, launchModal, requestProvider} from '@getalby/bitcoin-connect-react'; +import {LightningAddress} from '@getalby/lightning-tools'; +import {webln} from '@getalby/sdk'; +import React, {useRef} from 'react'; +import {Platform, ScrollView, Text, TextInput, View} from 'react-native'; +import WebView, {WebViewMessageEvent} from 'react-native-webview'; import PolyfillCrypto from 'react-native-webview-crypto'; -import { Button, Input } from '../../components'; -import { useStyles } from '../../hooks'; +import {Button, Input} from '../../components'; +import {useStyles} from '../../hooks'; import stylesheet from './styles'; export const LightningNetworkWalletView: React.FC = () => { const styles = useStyles(stylesheet); return ( - + // + + - + + + // ); }; function LightningNetworkWallet() { const styles = useStyles(stylesheet); - const [amountSats, setAmountSats] = React.useState("1") + const [amountSats, setAmountSats] = React.useState('1'); const [nwcUrl, setNwcUrl] = React.useState(''); const [pendingNwcUrl, setPendingNwcUrl] = React.useState(''); @@ -33,6 +37,30 @@ function LightningNetworkWallet() { const [nostrWebLN, setNostrWebLN] = React.useState( undefined, ); + const webviewRef = useRef(null); + + const onMessage = (event: WebViewMessageEvent) => { + const {data} = event.nativeEvent; + console.log('Received message from WebView:', data); + + // Handle messages sent from the WebView, e.g., invoice payment status + }; + + const injectJavaScript = ` + (async function() { + if (window.webln) { + try { + await window.webln.enable(); + const invoice = await window.webln.makeInvoice({ amount: 1000, memo: "React Native Zap" }); + window.ReactNativeWebView.postMessage(JSON.stringify({ type: "invoice", data: invoice })); + } catch (error) { + window.ReactNativeWebView.postMessage(JSON.stringify({ type: "error", message: error.message })); + } + } else { + window.ReactNativeWebView.postMessage(JSON.stringify({ type: "error", message: "WebLN not available" })); + } + })(); +`; const [balance, setBalance] = React.useState(); React.useEffect(() => { @@ -96,6 +124,7 @@ function LightningNetworkWallet() { async function connectWithAlby() { const nwc = webln.NostrWebLNProvider.withNewSecret({ //authorizationUrl: "http://192.168.1.102:8080", + // authorizationUrl:nwcAuthUrl }); console.log('nwc', nwc); @@ -137,23 +166,44 @@ function LightningNetworkWallet() { // } const handleRequest = async () => { - let modal = launchModal(); - // const provider = await requestProvider(); + const modal = launchModal(); + const provider = await requestProvider(); // let send_payment = await provider.sendPayment('lnbc...'); return; }; + return ( - + + {/* + { + Platform.OS == "web" ? + + : + + } + */} - { - Platform.OS == 'web' ? ( - + + Paste NWC URL setNwcUrl(text)} @@ -187,11 +238,11 @@ function LightningNetworkWallet() { - + + ); } diff --git a/apps/mobile/src/modules/PixelPeace/index.tsx b/apps/mobile/src/modules/PixelPeace/index.tsx index 7034ea90..aa4d4e1a 100644 --- a/apps/mobile/src/modules/PixelPeace/index.tsx +++ b/apps/mobile/src/modules/PixelPeace/index.tsx @@ -1,40 +1,32 @@ -import React, { useEffect, useState } from 'react'; -import { useStyles } from '../../hooks'; -import stylesheet from './styles'; -import { Chat } from '../../components/PrivateMessages/Chat'; +import React from 'react'; +import {Platform, View} from 'react-native'; // import { App, AppRender } from "pixel_ui" import WebView from 'react-native-webview'; -import { Platform, View } from 'react-native'; -import { useIsDesktop } from '../../hooks/useIsDesktop'; -export const PixelPeace: React.FC = () => { +import {useStyles} from '../../hooks'; +import {useIsDesktop} from '../../hooks/useIsDesktop'; +import stylesheet from './styles'; +export const PixelPeace: React.FC = () => { const styles = useStyles(stylesheet); - const isDesktop = useIsDesktop() - + const isDesktop = useIsDesktop(); return ( - - - {Platform.OS == "web" && process.env.EXPO_PUBLIC_PIXEL_URL && + + {Platform.OS == 'web' && process.env.EXPO_PUBLIC_PIXEL_URL && ( <> - - - } + )} - {Platform.OS != "web" && process.env.EXPO_PUBLIC_PIXEL_URL && - - - } + {Platform.OS != 'web' && process.env.EXPO_PUBLIC_PIXEL_URL && ( + + )} - ); -}; \ No newline at end of file +}; diff --git a/apps/mobile/src/modules/PixelPeace/styles.ts b/apps/mobile/src/modules/PixelPeace/styles.ts index 5535fb41..992c9a4d 100644 --- a/apps/mobile/src/modules/PixelPeace/styles.ts +++ b/apps/mobile/src/modules/PixelPeace/styles.ts @@ -1,12 +1,12 @@ import {ThemedStyleSheet} from '../../styles'; export default ThemedStyleSheet((theme) => ({ - container: { - flex: 1, - backgroundColor: theme.colors.background, - }, - separator: { - height: 1, - backgroundColor: theme.colors.divider, - }, + container: { + flex: 1, + backgroundColor: theme.colors.background, + }, + separator: { + height: 1, + backgroundColor: theme.colors.divider, + }, })); diff --git a/apps/mobile/src/modules/Post/index.tsx b/apps/mobile/src/modules/Post/index.tsx index f267492e..9b203836 100644 --- a/apps/mobile/src/modules/Post/index.tsx +++ b/apps/mobile/src/modules/Post/index.tsx @@ -1,7 +1,14 @@ import {NDKEvent, NDKKind} from '@nostr-dev-kit/ndk'; import {useNavigation} from '@react-navigation/native'; import {useQueryClient} from '@tanstack/react-query'; -import {useProfile, useReact, useReactions, useReplyNotes, useRepost, useBookmark} from 'afk_nostr_sdk'; +import { + useBookmark, + useProfile, + useReact, + useReactions, + useReplyNotes, + useRepost, +} from 'afk_nostr_sdk'; // import { useAuth } from '../../store/auth'; import {useAuth} from 'afk_nostr_sdk'; import {useMemo, useState} from 'react'; @@ -27,12 +34,19 @@ import stylesheet from './styles'; export type PostProps = { asComment?: boolean; event?: NDKEvent; - repostedEventProps?:string; - isRepost?:boolean + repostedEventProps?: string; + isRepost?: boolean; + isBookmarked?: boolean; }; -export const Post: React.FC = ({asComment, event, repostedEventProps, isRepost}) => { - const repostedEvent = repostedEventProps ?? undefined; +export const Post: React.FC = ({ + asComment, + event, + repostedEventProps, + isRepost, + isBookmarked = false, +}) => { + const repostedEvent = repostedEventProps ?? undefined; const {theme} = useTheme(); const styles = useStyles(stylesheet); @@ -49,8 +63,8 @@ export const Post: React.FC = ({asComment, event, repostedEventProps, const comments = useReplyNotes({noteId: event?.id}); const react = useReact(); const queryClient = useQueryClient(); - const repostMutation = useRepost({ event }); - const { bookmarkNote } = useBookmark(publicKey); + const repostMutation = useRepost({event}); + const {bookmarkNote, removeBookmark} = useBookmark(publicKey); const [menuOpen, setMenuOpen] = useState(false); @@ -137,8 +151,13 @@ export const Post: React.FC = ({asComment, event, repostedEventProps, const handleBookmark = async () => { if (!event) return; try { - await bookmarkNote({ event }); - showToast({title: 'Post bookmarked successfully', type: 'success'}); + if (isBookmarked) { + await removeBookmark({eventId: event.id}); + showToast({title: 'Post removed from bookmarks', type: 'success'}); + } else { + await bookmarkNote({event}); + showToast({title: 'Post bookmarked successfully', type: 'success'}); + } } catch (error) { console.error('Bookmark error:', error); showToast({title: 'Failed to bookmark', type: 'error'}); @@ -150,12 +169,14 @@ export const Post: React.FC = ({asComment, event, repostedEventProps, return ( - {repostedEvent || event?.kind == NDKKind.Repost || isRepost && ( - - - Reposted - - )} + {repostedEvent || + event?.kind == NDKKind.Repost || + (isRepost && ( + + + Reposted + + ))} @@ -292,11 +313,12 @@ export const Post: React.FC = ({asComment, event, repostedEventProps, {repostMutation.isPending && } - - + + diff --git a/apps/mobile/src/modules/PostCard/index.tsx b/apps/mobile/src/modules/PostCard/index.tsx index 8d3f8c85..9a6950cc 100644 --- a/apps/mobile/src/modules/PostCard/index.tsx +++ b/apps/mobile/src/modules/PostCard/index.tsx @@ -1,27 +1,36 @@ -import { NDKEvent, NDKKind } from '@nostr-dev-kit/ndk'; -import { View } from 'react-native'; -import { useStyles } from '../../hooks'; -import { Post } from '../Post'; +import {NDKEvent, NDKKind} from '@nostr-dev-kit/ndk'; +import {useState} from 'react'; +import {View} from 'react-native'; + +import {useStyles} from '../../hooks'; +import {Post} from '../Post'; import stylesheet from './styles'; -import { useState } from 'react'; export type PostCardProps = { event?: NDKEvent; - isRepostProps?:boolean; + isRepostProps?: boolean; + isBookmarked?: boolean; }; -export const PostCard: React.FC = ({ event, isRepostProps }) => { +export const PostCard: React.FC = ({event, isRepostProps, isBookmarked}) => { const styles = useStyles(stylesheet); let repostedEvent = undefined; - const [isRepost, setIsRepost] = useState(isRepostProps ?? event?.kind == NDKKind.Repost ? true : false) + const [isRepost, setIsRepost] = useState( + isRepostProps ?? event?.kind == NDKKind.Repost ? true : false, + ); if (event?.kind == NDKKind.Repost) { - repostedEvent = JSON.stringify(event?.content) + repostedEvent = JSON.stringify(event?.content); } return ( - + ); }; diff --git a/apps/mobile/src/screens/Auth/Login.tsx b/apps/mobile/src/screens/Auth/Login.tsx index ddf60e1f..c36d4a63 100644 --- a/apps/mobile/src/screens/Auth/Login.tsx +++ b/apps/mobile/src/screens/Auth/Login.tsx @@ -169,12 +169,14 @@ export const Login: React.FC = ({navigation}) => { Create Account Import Account Nostr extension diff --git a/apps/mobile/src/screens/ChannelDetail/index.tsx b/apps/mobile/src/screens/ChannelDetail/index.tsx index 684bff19..c25f99da 100644 --- a/apps/mobile/src/screens/ChannelDetail/index.tsx +++ b/apps/mobile/src/screens/ChannelDetail/index.tsx @@ -1,10 +1,10 @@ import {View} from 'react-native'; +import {Header, IconButton} from '../../components'; import {useStyles} from '../../hooks'; import {ChannelDetailComponent} from '../../modules/ChannelDetailPage'; import {ChannelDetailScreenProps} from '../../types'; import stylesheet from './styles'; -import { Header, IconButton } from '../../components'; export const ChannelDetail: React.FC = ({navigation, route}) => { const {postId, post} = route.params; @@ -15,10 +15,9 @@ export const ChannelDetail: React.FC = ({navigation, r
} + left={} // right={} - + title="ChannelDetail" /> diff --git a/apps/mobile/src/screens/Feed/index.tsx b/apps/mobile/src/screens/Feed/index.tsx index 9f24d652..4ce98011 100644 --- a/apps/mobile/src/screens/Feed/index.tsx +++ b/apps/mobile/src/screens/Feed/index.tsx @@ -1,22 +1,22 @@ -import { NDKKind } from '@nostr-dev-kit/ndk'; -import { useAllProfiles, useContacts, useSearch, useSearchNotes } from 'afk_nostr_sdk'; -import { useState } from 'react'; -import { ActivityIndicator, FlatList, Image, Pressable, RefreshControl, View } from 'react-native'; +import {NDKKind} from '@nostr-dev-kit/ndk'; +import {useAllProfiles, useSearch} from 'afk_nostr_sdk'; +import {useState} from 'react'; +import {ActivityIndicator, FlatList, Image, Pressable, RefreshControl, View} from 'react-native'; -import { AddPostIcon } from '../../assets/icons'; -import { BubbleUser } from '../../components/BubbleUser'; +import {AddPostIcon} from '../../assets/icons'; +import {BubbleUser} from '../../components/BubbleUser'; import SearchComponent from '../../components/search'; -import { useStyles, useTheme } from '../../hooks'; -import { ChannelComponent } from '../../modules/ChannelCard'; -import { PostCard } from '../../modules/PostCard'; -import { FeedScreenProps } from '../../types'; +import {useStyles, useTheme} from '../../hooks'; +import {ChannelComponent} from '../../modules/ChannelCard'; +import {PostCard} from '../../modules/PostCard'; +import {FeedScreenProps} from '../../types'; import stylesheet from './styles'; -export const Feed: React.FC = ({ navigation }) => { - const { theme } = useTheme(); +export const Feed: React.FC = ({navigation}) => { + const {theme} = useTheme(); const styles = useStyles(stylesheet); - const profiles = useAllProfiles(); - const [activeSortBy, setSortBy] = useState() + const profiles = useAllProfiles({limit: 10}); + const [activeSortBy, setSortBy] = useState(); const [search, setSearch] = useState(undefined); const [kinds, setKinds] = useState([ NDKKind.Text, @@ -26,27 +26,28 @@ export const Feed: React.FC = ({ navigation }) => { NDKKind.Metadata, ]); - const contacts = useContacts() - console.log("contacts", contacts) + // const contacts = useContacts() + // console.log("contacts", contacts) const notes = useSearch({ // search: search, kinds, + limit: 10, // authors: activeSortBy && contacts?.data?.?? [], // sortBy: activeSortBy, }); - // Filter profiles based on the search query const profilesSearch = - profiles?.data?.pages - ?.flat() + profiles?.data?.pages?.flat() ?? // .filter((item) => (search && search?.length > 0 ? item?.content?.includes(search) : true)) ?? - ?? []; + []; // Filter notes based on the search query - const filteredNotes = notes.data?.pages - .flat() - .filter((item) => (search && search?.length > 0 ? item?.content?.includes(search) : true)) ?? []; + const filteredNotes = + notes.data?.pages + .flat() + .filter((item) => (search && search?.length > 0 ? item?.content?.includes(search) : true)) ?? + []; return ( @@ -63,36 +64,41 @@ export const Feed: React.FC = ({ navigation }) => { setKinds={setKinds} setSortBy={setSortBy} sortBy={activeSortBy} - contactList={contacts?.data?.map((item) => item)} + // contactList={contacts?.data?.map((item) => item)} /> {notes?.isLoading && } + {notes?.data?.pages?.length == 0 && } - profiles.fetchNextPage()} - refreshControl={ - profiles.refetch()} /> - } - ItemSeparatorComponent={() => } - renderItem={({ item }) => } - />} + ListHeaderComponent={ + <> + profiles.fetchNextPage()} + refreshControl={ + profiles.refetch()} + /> + } + ItemSeparatorComponent={() => } + renderItem={({item}) => } + /> + + } contentContainerStyle={styles.flatListContent} data={filteredNotes} keyExtractor={(item) => item?.id} - renderItem={({ item }) => { + renderItem={({item}) => { if (item.kind === NDKKind.ChannelCreation || item.kind === NDKKind.ChannelMetadata) { return ; - } - else if (item.kind === NDKKind.ChannelMessage) { + } else if (item.kind === NDKKind.ChannelMessage) { return ; - } - else if (item.kind === NDKKind.Text) { + } else if (item.kind === NDKKind.Text) { return ; } return <>; @@ -105,7 +111,7 @@ export const Feed: React.FC = ({ navigation }) => { navigation.navigate('MainStack', { screen: 'CreateForm' })} + onPress={() => navigation.navigate('MainStack', {screen: 'CreateForm'})} > diff --git a/apps/mobile/src/screens/Games/index.tsx b/apps/mobile/src/screens/Games/index.tsx index 79deae53..9c721b5e 100644 --- a/apps/mobile/src/screens/Games/index.tsx +++ b/apps/mobile/src/screens/Games/index.tsx @@ -5,13 +5,13 @@ import {SafeAreaView} from 'react-native-safe-area-context'; import {TextButton} from '../../components'; import TabSelector from '../../components/TabSelector'; import {useStyles, useTheme} from '../../hooks'; +import {PixelPeace} from '../../modules/PixelPeace'; import {GameSreenProps} from '../../types'; import {SelectedTab, TABS_MENU} from '../../types/tab'; import {AllKeysComponent} from '../KeysMarketplace/AllKeysComponent'; import {LaunchpadComponent} from '../Launchpad/LaunchpadComponent'; import {SlinksMap} from '../Slink/SlinksMap'; import stylesheet from './styles'; -import { PixelPeace } from '../../modules/PixelPeace'; export const Games: React.FC = ({navigation}) => { const theme = useTheme(); @@ -41,8 +41,7 @@ export const Games: React.FC = ({navigation}) => { > - - {selectedTab == SelectedTab.PIXEL_PEACE && ( + {selectedTab == SelectedTab.PIXEL_PEACE && ( <> diff --git a/apps/mobile/src/screens/Lightning/index.tsx b/apps/mobile/src/screens/Lightning/index.tsx index b1110bb2..1d10db09 100644 --- a/apps/mobile/src/screens/Lightning/index.tsx +++ b/apps/mobile/src/screens/Lightning/index.tsx @@ -1,13 +1,12 @@ import React from 'react'; -import {View} from 'react-native'; +import {ScrollView} from 'react-native'; import {LightningNetworkWalletView} from '../../modules/Lightning'; import {LightningNetworkScreenProps} from '../../types'; - export const LightningNetworkScreen: React.FC = () => { return ( - + - + ); }; diff --git a/apps/mobile/src/screens/Profile/index.tsx b/apps/mobile/src/screens/Profile/index.tsx index ddefbcc0..beeb61d1 100644 --- a/apps/mobile/src/screens/Profile/index.tsx +++ b/apps/mobile/src/screens/Profile/index.tsx @@ -1,31 +1,64 @@ -import { useReposts, useRootNotes, useSearch, useSearchNotes } from 'afk_nostr_sdk'; -import { ActivityIndicator, FlatList, Pressable, RefreshControl, ScrollView, View } from 'react-native'; +import {NDKKind} from '@nostr-dev-kit/ndk'; +import {useBookmark, useSearch} from 'afk_nostr_sdk'; +import {useMemo, useState} from 'react'; +import { + ActivityIndicator, + FlatList, + Pressable, + RefreshControl, + ScrollView, + View, +} from 'react-native'; -import { useStyles } from '../../hooks'; -import { PostCard } from '../../modules/PostCard'; -import { ProfileScreenProps } from '../../types'; -import { ProfileInfo } from './Info'; +import {Text} from '../../components'; +import {useStyles} from '../../hooks'; +import {PostCard} from '../../modules/PostCard'; +import {ProfileScreenProps} from '../../types'; +import {ProfileInfo} from './Info'; import stylesheet from './styles'; -import { useMemo, useState } from 'react'; -import { NDKKind } from '@nostr-dev-kit/ndk'; -import { Button, Text } from '../../components'; -export const Profile: React.FC = ({ route }) => { - const { publicKey } = route.params ?? {}; +export const Profile: React.FC = ({route}) => { + const {publicKey} = route.params ?? {}; const styles = useStyles(stylesheet); - const [ndkKind, setNdkKind] = useState(NDKKind.Text) + const [ndkKinds, setNdkKind] = useState([NDKKind.Text]); const kindFilter = useMemo(() => { - return ndkKind - }, [ndkKind]) + return ndkKinds; + }, [ndkKinds]); - const notesSearch = useRootNotes({ authors: [publicKey] }); - const search = useSearch({ authors: [publicKey], kind: kindFilter }); - const reposts = useReposts({ authors: [publicKey] }); + // const notesSearch = useRootNotes({ authors: [publicKey] }); + const search = useSearch({authors: [publicKey], kinds: kindFilter}); + // const reposts = useReposts({ authors: [publicKey] }); + const {bookmarksWithNotes} = useBookmark(publicKey); + + // Extract all bookmarked note IDs + const bookmarkedNoteIds = useMemo(() => { + if (!bookmarksWithNotes) return new Set(); + + const ids = new Set(); + bookmarksWithNotes.forEach((bookmark) => { + bookmark.notes.forEach((note) => { + ids.add(note?.id || ''); + }); + }); + return ids; + }, [bookmarksWithNotes]); + + // Function to check if a note is bookmarked + const isBookmarked = (noteId: string) => bookmarkedNoteIds.has(noteId); + + // const getData = ndkKinds.includes(NDKKind.BookmarkList) || ndkKinds.includes(NDKKind.BookmarkSet) + // ? bookmarksWithNotes?.map(bookmark => bookmark.notes).flat() || [] + // : search.data?.pages.flat(); + + // console.log("getData", getData) + + const getData = search.data?.pages.flat(); + + console.log('getData', getData); return ( - @@ -33,46 +66,47 @@ export const Profile: React.FC = ({ route }) => { - setNdkKind(NDKKind.Text)}> + setNdkKind([NDKKind.Text])} + style={[styles.option, ndkKinds.includes(NDKKind.Text) && styles.selected]} + > Notes - setNdkKind(NDKKind.Repost)}> + setNdkKind([NDKKind.Repost])} + style={[styles.option, ndkKinds.includes(NDKKind.Repost) && styles.selected]} + > Repost + setNdkKind([NDKKind.BookmarkList, NDKKind.BookmarkSet])} + style={[styles.option, ndkKinds.includes(NDKKind.BookmarkList) && styles.selected]} + > + Bookmarks + } - data={search.data?.pages.flat()} + data={getData} keyExtractor={(item) => item.id} - renderItem={({ item }) => { - if (ndkKind == NDKKind.Repost) { - const itemReposted = JSON.parse(item?.content) - return + renderItem={({item}) => { + if (!item) return <>; + if (ndkKinds.includes(NDKKind.Repost)) { + const itemReposted = JSON.parse(item?.content); + return ; } - return + return ; }} refreshControl={ search.refetch()} /> } /> + {search?.isPending && } + {search?.isLoading && } ); diff --git a/apps/mobile/src/screens/Profile/styles.ts b/apps/mobile/src/screens/Profile/styles.ts index e62901bd..fa78209c 100644 --- a/apps/mobile/src/screens/Profile/styles.ts +++ b/apps/mobile/src/screens/Profile/styles.ts @@ -5,4 +5,33 @@ export default ThemedStyleSheet((theme) => ({ flex: 1, backgroundColor: theme.colors.background, }, + optionsContentContainer: { + paddingVertical: 5, + paddingHorizontal: 5, + flexDirection: 'row', + rowGap: 3, + gap: 3, + columnGap: 15, + }, + optionsContainer: { + paddingHorizontal: 5, + paddingVertical: 5, + flexDirection: 'row', + rowGap: 3, + gap: 3, + columnGap: 3, + }, + option: { + paddingVertical: 10, + paddingHorizontal: 20, + borderWidth: 1, + borderStyle: 'solid', + borderColor: theme.colors.primary, + borderRadius: 20, + color: theme.colors.textLight, + }, + selected: { + backgroundColor: theme.colors.primary, + color: theme.colors.text, + }, })); diff --git a/apps/mobile/src/screens/Tips/styles.ts b/apps/mobile/src/screens/Tips/styles.ts index ba9d815e..bc289af6 100644 --- a/apps/mobile/src/screens/Tips/styles.ts +++ b/apps/mobile/src/screens/Tips/styles.ts @@ -4,6 +4,8 @@ export default ThemedStyleSheet((theme) => ({ container: { flex: 1, backgroundColor: theme.colors.background, + paddingVertical: Spacing.xxxsmall, + paddingHorizontal: Spacing.normal, }, flatListContent: { diff --git a/apps/mobile/src/screens/Whatever/index.tsx b/apps/mobile/src/screens/Whatever/index.tsx index a87f2ef9..5d66c3e2 100644 --- a/apps/mobile/src/screens/Whatever/index.tsx +++ b/apps/mobile/src/screens/Whatever/index.tsx @@ -1,6 +1,7 @@ import {NDKEvent, NDKKind} from '@nostr-dev-kit/ndk'; import {useNavigation} from '@react-navigation/native'; import {useAccount, useProvider} from '@starknet-react/core'; +import {useNostrContext} from 'afk_nostr_sdk'; import {useState} from 'react'; import {View} from 'react-native'; import {byteArray, cairo, CallData, getChecksumAddress, uint256} from 'starknet'; @@ -17,7 +18,6 @@ import {ChannelComponent} from '../../modules/ChannelCard'; import {MainStackNavigationProps} from '../../types'; import {TipsComponent} from '../Tips/TipsComponent'; import stylesheet from './styles'; -import { useNostrContext } from 'afk_nostr_sdk'; enum SelectedTab { TIPS, diff --git a/apps/mobile/src/styles/Colors.tsx b/apps/mobile/src/styles/Colors.tsx index 940523d5..5cf1bba3 100644 --- a/apps/mobile/src/styles/Colors.tsx +++ b/apps/mobile/src/styles/Colors.tsx @@ -21,6 +21,9 @@ export const LightTheme = { messageCard: '#FFFFFF', messageCardText: '#14142C', + messageReplyCard: '#E0E0E0', + messageReplyCardText: '#14142C', + // primary: '#EC796B', primary: '#4FA89B', primaryLight: 'rgba(236,185,107, 0.1)', @@ -42,6 +45,7 @@ export const LightTheme = { onSecondary: '#FFFFFF', divider: '#e4e4e7', + sidebarDivider: '#e4e4e7', bottomBarActive: '#14142C', bottomBarInactive: 'rgba(30, 47, 61, 0.5)', @@ -76,6 +80,9 @@ export const DarkTheme = { messageCard: '#2C2C2C', messageCardText: '#FFFFFF', + messageReplyCard: '#1F1F1F', + messageReplyCardText: '#E0E0E0', + // primary: '#EC796B', primary: '#4FA89B', primaryLight: 'rgba(236,185,107, 0.1)', @@ -97,6 +104,7 @@ export const DarkTheme = { onSecondary: '#FFFFFF', divider: '#1b1b18', + sidebarDivider: '#FFFFFF', bottomBarActive: '#8F979E', bottomBarInactive: 'rgb(105,105,105, 0.5)', diff --git a/apps/mobile/src/types/messages.ts b/apps/mobile/src/types/messages.ts index 48fa0540..250d76d2 100644 --- a/apps/mobile/src/types/messages.ts +++ b/apps/mobile/src/types/messages.ts @@ -9,7 +9,7 @@ export type Message = { message: string; isUser: boolean; // Indicates if the message is from the current user or the other party timestamp: string; -} +}; export type ConversationType = { id: string; diff --git a/apps/mobile/src/types/nostr.ts b/apps/mobile/src/types/nostr.ts index 00fb5eb2..b23ef7ff 100644 --- a/apps/mobile/src/types/nostr.ts +++ b/apps/mobile/src/types/nostr.ts @@ -1,6 +1,5 @@ export enum SORT_OPTION_EVENT_NOSTR { - TIME, - TRENDING, - FOR_YOU - -} \ No newline at end of file + TIME, + TRENDING, + FOR_YOU, +} diff --git a/apps/mobile/src/types/routes.ts b/apps/mobile/src/types/routes.ts index 46f620e9..2149ff13 100644 --- a/apps/mobile/src/types/routes.ts +++ b/apps/mobile/src/types/routes.ts @@ -27,8 +27,14 @@ export type MainStackParams = { CreatePost: undefined; Profile: {publicKey: string}; PostDetail: {postId: string; post?: NDKEvent}; - GroupChat: {groupId: string; post?: NDKEvent}; - GroupChatDetail: {groupId: string; post?: NDKEvent}; + GroupChat: {groupId: string; post?: NDKEvent; groupName: string; groupAccess: string}; + GroupChatDetail: {groupId: string; groupName: string; post?: NDKEvent; groupAccess: string}; + GroupChatMemberRequest: { + groupId: string; + groupName: string; + post?: NDKEvent; + groupAccess: string; + }; EditProfile: undefined; Search: undefined; CreateChannel: undefined; @@ -56,7 +62,7 @@ export type MainStackParams = { PrivateGroupDetails: {postId: string; post?: NDKEvent}; Lightning: undefined; Whatever: undefined; - RightDrawer:undefined; + RightDrawer: undefined; }; export type DegensAppStackParams = { @@ -232,6 +238,10 @@ export type GroupChatDetailScreenProps = CompositeScreenProps< NativeStackScreenProps, NativeStackScreenProps >; +export type GroupChatMemberRequestScreenProps = CompositeScreenProps< + NativeStackScreenProps, + NativeStackScreenProps +>; export type ChannelDetailScreenProps = CompositeScreenProps< NativeStackScreenProps, diff --git a/apps/mobile/src/types/tab.ts b/apps/mobile/src/types/tab.ts index 3b88d3a8..2adcbe45 100644 --- a/apps/mobile/src/types/tab.ts +++ b/apps/mobile/src/types/tab.ts @@ -23,7 +23,6 @@ export enum SelectedTab { ALL_GROUP, GROUP_MESSAGE, PIXEL_PEACE, - } export const TABS_TIP_LIST: {screen?: string; title: string; tab: SelectedTab}[] = [ @@ -33,9 +32,9 @@ export const TABS_TIP_LIST: {screen?: string; title: string; tab: SelectedTab}[] tab: SelectedTab.TIPS, }, { - title: 'Messages', - screen: 'Messages', - tab: SelectedTab.MESSAGES, + title: 'All Group', + screen: 'AllGroup', + tab: SelectedTab.ALL_GROUP, }, { title: 'Channels', @@ -43,9 +42,9 @@ export const TABS_TIP_LIST: {screen?: string; title: string; tab: SelectedTab}[] tab: SelectedTab.CHANNELS, }, { - title: 'All Group', - screen: 'AllGroup', - tab: SelectedTab.ALL_GROUP, + title: 'Messages', + screen: 'Messages', + tab: SelectedTab.MESSAGES, }, // { // title: 'Messages', @@ -150,7 +149,7 @@ export const TABS_MENU: {screen?: string; title: string; tab: SelectedTab}[] = [ screen: 'Slink', tab: SelectedTab.SLINK, }, - + { title: '?', screen: '?', diff --git a/apps/mobile/src/utils/dummyData.ts b/apps/mobile/src/utils/dummyData.ts index d730a28e..ae067493 100644 --- a/apps/mobile/src/utils/dummyData.ts +++ b/apps/mobile/src/utils/dummyData.ts @@ -1,225 +1,225 @@ -import { Conversation } from "../types/messages"; +import {Conversation} from '../types/messages'; export const conversationsData: Conversation[] = [ - { - id: '1', - user: { - id: '1', - name: 'John Doe', - handle: '@johndoe', - avatar: '', - }, - messages: [ - { - message: 'Doing well, thanks!', - isUser: true, - timestamp: new Date().toString(), - }, - { - message: 'How are you?', - isUser: false, - timestamp: new Date().toString(), - }, - { - message: 'Hello', - isUser: false, - timestamp: new Date().toString(), - }, - { - message: 'Hi!', - isUser: true, - timestamp: new Date().toString(), - }, - ] - }, - { - id: '2', - user: { - id: '2', - name: 'Jane Doe', - handle: '@janedoe', - avatar: '', - }, - messages: [] - }, - { - id: '3', - user: { - id: '3', - name: 'Alice', - handle: '@alice', - avatar: '', - }, - messages: [] - }, - { - id: '4', - user: { - id: '4', - name: 'Bob', - handle: '@bob', - avatar: '', - }, - messages: [] - }, - { - id: '5', - user: { - id: '5', - name: 'Charlie', - handle: '@charlie', - avatar: '', - }, - messages: [] - }, - { - id: '6', - user: { - id: '6', - name: 'David', - handle: '@david', - avatar: '', - }, - messages: [] - }, - { - id: '7', - user: { - id: '7', - name: 'Eve', - handle: '@eve', - avatar: '', - }, - messages: [] - }, - { - id: '8', - user: { - id: '8', - name: 'Frank', - handle: '@frank', - avatar: '', - }, - messages: [] - }, - { - id: '9', - user: { - id: '9', - name: 'Grace', - handle: '@grace', - avatar: '', - }, - messages: [] - }, - { - id: '10', - user: { - id: '10', - name: 'Harry', - handle: '@harry', - avatar: '', - }, - messages: [] - }, - { - id: '11', - user: { - id: '11', - name: 'Ivy', - handle: '@ivy', - avatar: '', - }, - messages: [] - }, - { - id: '12', - user: { - id: '12', - name: 'Jack', - handle: '@jack', - avatar: '', - }, - messages: [] - }, - { - id: '13', - user: { - id: '13', - name: 'Kate', - handle: '@kate', - avatar: '', - }, - messages: [] - }, - { - id: '14', - user: { - id: '14', - name: 'Liam', - handle: '@liam', - avatar: '', - }, - messages: [] - }, - { - id: '15', - user: { - id: '15', - name: 'Mia', - handle: '@mia', - avatar: '', - }, - messages: [] - }, - { - id: '16', - user: { - id: '16', - name: 'Noah', - handle: '@noah', - avatar: '', - }, - messages: [] - }, - { - id: '17', - user: { - id: '17', - name: 'Olivia', - handle: '@olivia', - avatar: '', - }, - messages: [] - }, - { - id: '18', - user: { - id: '18', - name: 'Peter', - handle: '@peter', - avatar: '', - }, - messages: [] - }, - { - id: '19', - user: { - id: '19', - name: 'Quinn', - handle: '@quinn', - avatar: '', - }, - messages: [] - }, - { - id: '20', - user: { - id: '20', - name: 'Rose', - handle: '@rose', - avatar: '', - }, - messages: [] - } + { + id: '1', + user: { + id: '1', + name: 'John Doe', + handle: '@johndoe', + avatar: '', + }, + messages: [ + { + message: 'Doing well, thanks!', + isUser: true, + timestamp: new Date().toString(), + }, + { + message: 'How are you?', + isUser: false, + timestamp: new Date().toString(), + }, + { + message: 'Hello', + isUser: false, + timestamp: new Date().toString(), + }, + { + message: 'Hi!', + isUser: true, + timestamp: new Date().toString(), + }, + ], + }, + { + id: '2', + user: { + id: '2', + name: 'Jane Doe', + handle: '@janedoe', + avatar: '', + }, + messages: [], + }, + { + id: '3', + user: { + id: '3', + name: 'Alice', + handle: '@alice', + avatar: '', + }, + messages: [], + }, + { + id: '4', + user: { + id: '4', + name: 'Bob', + handle: '@bob', + avatar: '', + }, + messages: [], + }, + { + id: '5', + user: { + id: '5', + name: 'Charlie', + handle: '@charlie', + avatar: '', + }, + messages: [], + }, + { + id: '6', + user: { + id: '6', + name: 'David', + handle: '@david', + avatar: '', + }, + messages: [], + }, + { + id: '7', + user: { + id: '7', + name: 'Eve', + handle: '@eve', + avatar: '', + }, + messages: [], + }, + { + id: '8', + user: { + id: '8', + name: 'Frank', + handle: '@frank', + avatar: '', + }, + messages: [], + }, + { + id: '9', + user: { + id: '9', + name: 'Grace', + handle: '@grace', + avatar: '', + }, + messages: [], + }, + { + id: '10', + user: { + id: '10', + name: 'Harry', + handle: '@harry', + avatar: '', + }, + messages: [], + }, + { + id: '11', + user: { + id: '11', + name: 'Ivy', + handle: '@ivy', + avatar: '', + }, + messages: [], + }, + { + id: '12', + user: { + id: '12', + name: 'Jack', + handle: '@jack', + avatar: '', + }, + messages: [], + }, + { + id: '13', + user: { + id: '13', + name: 'Kate', + handle: '@kate', + avatar: '', + }, + messages: [], + }, + { + id: '14', + user: { + id: '14', + name: 'Liam', + handle: '@liam', + avatar: '', + }, + messages: [], + }, + { + id: '15', + user: { + id: '15', + name: 'Mia', + handle: '@mia', + avatar: '', + }, + messages: [], + }, + { + id: '16', + user: { + id: '16', + name: 'Noah', + handle: '@noah', + avatar: '', + }, + messages: [], + }, + { + id: '17', + user: { + id: '17', + name: 'Olivia', + handle: '@olivia', + avatar: '', + }, + messages: [], + }, + { + id: '18', + user: { + id: '18', + name: 'Peter', + handle: '@peter', + avatar: '', + }, + messages: [], + }, + { + id: '19', + user: { + id: '19', + name: 'Quinn', + handle: '@quinn', + avatar: '', + }, + messages: [], + }, + { + id: '20', + user: { + id: '20', + name: 'Rose', + handle: '@rose', + avatar: '', + }, + messages: [], + }, ]; diff --git a/apps/mobile/src/utils/storage.ts b/apps/mobile/src/utils/storage.ts index 47c745f5..96f5de62 100644 --- a/apps/mobile/src/utils/storage.ts +++ b/apps/mobile/src/utils/storage.ts @@ -65,16 +65,24 @@ export const retrieveAndDecryptPrivateKey = async (password: string): Promise { if (isSecureStoreAvailable) { return SecureStore.setItemAsync('password', password, {requireAuthentication: true}); } + // else { + // return await AsyncStorage.setItem('password', password); + // } }; +/** TODO add security for password retrieve in Web view? */ export const retrievePassword = async () => { if (isSecureStoreAvailable) { return SecureStore.getItemAsync('password', {requireAuthentication: true}); } + // else { + // return await AsyncStorage.getItem('password'); + // } return null; }; diff --git a/apps/website/.env.example b/apps/website/.env.example index 855305c1..a8e12e13 100644 --- a/apps/website/.env.example +++ b/apps/website/.env.example @@ -6,4 +6,10 @@ NETWORK_NAME="SN_SEPOLIA" # SN_SEPOLIA, SN_MAIN PINATA_API_KEY="YOUR_PINATA_API_KEY" PINATA_SECRET_API_KEY="YOUR_PINATA_SECRET_API_KEY" -IPFS_GATEWAT="https://your.ipfs.gateway.url/ipfs" \ No newline at end of file +IPFS_GATEWAY="https://ipfs.io/" + +REACT_APP_BACKEND_URL= +REACT_APP_USERNAME_STORE_CONTRACT_ADDRESS= +REACT_APP_CANVAS_NFT_CONTRACT_ADDRESS= +REACT_APP_USERNAME_STORE_CONTRACT_ADDRESS= +REACT_APP_NODE_ENV= diff --git a/apps/website/src/app/components/NavbarPixel.tsx b/apps/website/src/app/components/NavbarPixel.tsx new file mode 100644 index 00000000..5d54e083 --- /dev/null +++ b/apps/website/src/app/components/NavbarPixel.tsx @@ -0,0 +1,45 @@ +'use client'; + +import Link from 'next/link'; +import React, {useState} from 'react'; +import {createPortal} from 'react-dom'; + +import {MobileNavBar} from './MobileNavBar'; + +export function NavbarPixel() { + const [toggleNav, setToggleNav] = useState(false); + return ( +
+
+ + +
AFK
+ +
+ {/* */} + {/*
+ +
*/} + + + {toggleNav && + createPortal(, document.body)} +
+ ); +} diff --git a/apps/website/src/app/pixel/page.tsx b/apps/website/src/app/pixel/page.tsx index d0308dbc..a17ff72a 100644 --- a/apps/website/src/app/pixel/page.tsx +++ b/apps/website/src/app/pixel/page.tsx @@ -1,17 +1,17 @@ 'use client'; import {AppRender} from 'pixel_ui'; -import {Footer} from '../components/Footer'; -import {Navbar} from '../components/Navbar'; +import {NavbarPixel} from '../components/NavbarPixel'; export default function Pixel() { return (
- + {/* */} {typeof window !== 'undefined' && } -
+ {/* {typeof window !== 'undefined' && } */} + {/*
*/}
); } diff --git a/backend/.env.example b/backend/.env.example index 2fce1fe3..deb0192a 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,6 +1,16 @@ REDIS_HOST=localhost REDIS_PORT=6379 + POSTGRES_HOST=localhost POSTGRES_PORT=5432 POSTGRES_USER=postgres +POSTGRES_PASSWORD=postgres POSTGRES_DATABASE=postgres + +BACKEND_HOST=localhost +BACKEND_PORT=8082 +CONSUMER_PORT=8081 + +PRODUCTION=false + +BACKEND_URL=https://backend-pixel.onrender.com/ \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile index 038fd5f6..0f0229d5 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -16,9 +16,31 @@ COPY ./tests/integration/docker/ . # Copy over the app WORKDIR /app -COPY ./backend/go.mod ./backend/go.sum ./ +COPY ./go.mod ./go.sum ./ RUN go mod download -COPY ./backend . +COPY ./ . + +# Argument for Redis host and port +ARG REDIS_HOST +ARG REDIS_PORT + +# Set Redis host and port environment variables +ENV REDIS_HOST=${REDIS_HOST} +ENV REDIS_PORT=${REDIS_PORT} + +# Argument for Postgres host, port, user, and database +ARG POSTGRES_HOST +ARG POSTGRES_PORT +ARG POSTGRES_USER +ARG POSTGRES_PASSWORD +ARG POSTGRES_DATABASE + +# Set Postgres host, port, user, and database environment variables +ENV POSTGRES_HOST=${POSTGRES_HOST} +ENV POSTGRES_PORT=${POSTGRES_PORT} +ENV POSTGRES_USER=${POSTGRES_USER} +ENV POSTGRES_PASSWORD=${POSTGRES_PASSWORD} +ENV POSTGRES_DATABASE=${POSTGRES_DATABASE} # Argument for Redis host and port ARG REDIS_HOST diff --git a/backend/Dockerfile.consumer b/backend/Dockerfile.consumer index 38e4303a..84914ba8 100644 --- a/backend/Dockerfile.consumer +++ b/backend/Dockerfile.consumer @@ -23,12 +23,14 @@ ENV REDIS_PORT=${REDIS_PORT} ARG POSTGRES_HOST ARG POSTGRES_PORT ARG POSTGRES_USER +ARG POSTGRES_PASSWORD ARG POSTGRES_DATABASE # Set Postgres host, port, user, and database environment variables ENV POSTGRES_HOST=${POSTGRES_HOST} ENV POSTGRES_PORT=${POSTGRES_PORT} ENV POSTGRES_USER=${POSTGRES_USER} +ENV POSTGRES_PASSWORD=${POSTGRES_PASSWORD} ENV POSTGRES_DATABASE=${POSTGRES_DATABASE} # Build the app & run it diff --git a/backend/Dockerfile.consumer.prod b/backend/Dockerfile.consumer.prod index 118c266f..966929ce 100644 --- a/backend/Dockerfile.consumer.prod +++ b/backend/Dockerfile.consumer.prod @@ -2,9 +2,6 @@ FROM --platform=linux/amd64 golang:1.22.2-alpine RUN apk add --no-cache bash curl git jq -# Copy over the configs -COPY ./configs/prod-backend.config.json ./backend.config.json - # Copy over the app WORKDIR /app COPY ./go.mod ./go.sum ./ @@ -19,21 +16,33 @@ ARG REDIS_PORT ENV REDIS_HOST=${REDIS_HOST} ENV REDIS_PORT=${REDIS_PORT} -# Argument for Postgres host, port, user, and database +# Argument for Postgres host, port, user, password, and database ARG POSTGRES_HOST ARG POSTGRES_PORT ARG POSTGRES_USER +ARG POSTGRES_PASSWORD ARG POSTGRES_DATABASE -# Set Postgres host, port, user, and database environment variables +# Set Postgres host, port, user, password and database environment variables ENV POSTGRES_HOST=${POSTGRES_HOST} ENV POSTGRES_PORT=${POSTGRES_PORT} ENV POSTGRES_USER=${POSTGRES_USER} +ENV POSTGRES_PASSWORD=${POSTGRES_PASSWORD} ENV POSTGRES_DATABASE=${POSTGRES_DATABASE} +# Argument for Backend host, port, and consumer port +ARG BACKEND_HOST +ARG BACKEND_PORT +ARG CONSUMER_PORT + +# Set Backend host, port, and consumer port environment variables +ENV BACKEND_HOST=${BACKEND_HOST} +ENV BACKEND_PORT=${BACKEND_PORT} +ENV CONSUMER_PORT=${CONSUMER_PORT} + # Build the app & run it RUN go build -o consumer ./cmd/consumer/consumer.go -EXPOSE 8082 +EXPOSE 8081 CMD ["./consumer"] diff --git a/backend/Dockerfile.prod b/backend/Dockerfile.prod index 430edf6a..1d63eef3 100644 --- a/backend/Dockerfile.prod +++ b/backend/Dockerfile.prod @@ -7,9 +7,29 @@ COPY ./configs/prod-backend.config.json ./backend.config.json # Copy over the app WORKDIR /app -COPY ./backend/go.mod ./backend/go.sum ./ +COPY ./go.mod ./go.sum ./ RUN go mod download -COPY ./backend . +COPY ./ . + +# Argument for Redis host and port +ARG REDIS_HOST +ARG REDIS_PORT + +# Set Redis host and port environment variables +ENV REDIS_HOST=${REDIS_HOST} +ENV REDIS_PORT=${REDIS_PORT} + +# Argument for Postgres host, port, user, and database +ARG POSTGRES_HOST +ARG POSTGRES_PORT +ARG POSTGRES_USER +ARG POSTGRES_DATABASE + +# Set Postgres host, port, user, and database environment variables +ENV POSTGRES_HOST=${POSTGRES_HOST} +ENV POSTGRES_PORT=${POSTGRES_PORT} +ENV POSTGRES_USER=${POSTGRES_USER} +ENV POSTGRES_DATABASE=${POSTGRES_DATABASE} # Argument for Redis host and port ARG REDIS_HOST diff --git a/backend/README.md b/backend/README.md index 1f88f975..aa0bd7c3 100644 --- a/backend/README.md +++ b/backend/README.md @@ -12,6 +12,7 @@ REDIS_PORT=your_redis_port POSTGRES_HOST=your_postgres_host POSTGRES_PORT=your_postgres_port POSTGRES_USER=your_postgres_user +POSTGRES_PASSWORD=your_postgres_password POSTGRES_DATABASE=your_postgres_database ``` @@ -37,13 +38,13 @@ go build Build the image ```sh -docker build -f Dockerfile.consumer.prod -t afk-backend . +docker build -f Dockerfile.consumer.prod -t consumer-app . ``` Run the container ```sh -docker run --env-file .env -d -p 8082:8082 --name afk-backend afk-backend +docker run --env-file .env -d -p 8081:8081 --name consumer-app consumer-app ``` ## TODO diff --git a/backend/cmd/backend/backend.go b/backend/cmd/backend/backend.go index 0c91fbec..33210d89 100644 --- a/backend/cmd/backend/backend.go +++ b/backend/cmd/backend/backend.go @@ -23,12 +23,11 @@ func isFlagSet(name string) bool { func main() { err := godotenv.Load() - if err != nil { - log.Fatal("Error loading .env file") - } - + if err != nil { + log.Fatal("Error loading .env file") + } + canvasConfigFilename := flag.String("canvas-config", config.DefaultCanvasConfigPath, "Canvas config file") - backendConfigFilename := flag.String("backend-config", config.DefaultBackendConfigPath, "Backend config file") production := flag.Bool("production", false, "Production mode") admin := flag.Bool("admin", false, "Admin mode") @@ -44,7 +43,7 @@ func main() { panic(err) } - backendConfig, err := config.LoadBackendConfig(*backendConfigFilename) + backendConfig, err := config.LoadBackendConfig() if err != nil { panic(err) } diff --git a/backend/cmd/consumer/consumer.go b/backend/cmd/consumer/consumer.go index 5c8f2f32..77332ba7 100644 --- a/backend/cmd/consumer/consumer.go +++ b/backend/cmd/consumer/consumer.go @@ -2,11 +2,14 @@ package main import ( "flag" + "log" "github.com/AFK-AlignedFamKernel/afk_monorepo/backend/config" "github.com/AFK-AlignedFamKernel/afk_monorepo/backend/core" "github.com/AFK-AlignedFamKernel/afk_monorepo/backend/routes" "github.com/AFK-AlignedFamKernel/afk_monorepo/backend/routes/indexer" + + "github.com/joho/godotenv" ) func isFlagSet(name string) bool { @@ -20,8 +23,12 @@ func isFlagSet(name string) bool { } func main() { + err := godotenv.Load() + if err != nil { + log.Fatal("Error loading .env file") + } + canvasConfigFilename := flag.String("canvas-config", config.DefaultCanvasConfigPath, "Canvas config file") - backendConfigFilename := flag.String("backend-config", config.DefaultBackendConfigPath, "Backend config file") production := flag.Bool("production", false, "Production mode") flag.Parse() @@ -36,7 +43,7 @@ func main() { panic(err) } - backendConfig, err := config.LoadBackendConfig(*backendConfigFilename) + backendConfig, err := config.LoadBackendConfig() if err != nil { panic(err) } diff --git a/backend/cmd/video-gen/video.go b/backend/cmd/video-gen/video.go index 8d8a4e14..f1d87216 100644 --- a/backend/cmd/video-gen/video.go +++ b/backend/cmd/video-gen/video.go @@ -11,7 +11,6 @@ import ( func main() { canvasConfigFilename := flag.String("canvas-config", config.DefaultCanvasConfigPath, "Canvas config file") - backendConfigFilename := flag.String("backend-config", config.DefaultBackendConfigPath, "Backend config file") flag.Parse() @@ -25,7 +24,7 @@ func main() { panic(err) } - backendConfig, err := config.LoadBackendConfig(*backendConfigFilename) + backendConfig, err := config.LoadBackendConfig() if err != nil { panic(err) } diff --git a/backend/config/backend.go b/backend/config/backend.go index f523337d..63d22a6c 100644 --- a/backend/config/backend.go +++ b/backend/config/backend.go @@ -1,99 +1,99 @@ package config import ( - "encoding/json" + "fmt" "os" + "strconv" ) type BackendScriptsConfig struct { - PlacePixelDevnet string `json:"place_pixel_devnet"` - PlaceExtraPixelsDevnet string `json:"place_extra_pixels_devnet"` - AddTemplateDevnet string `json:"add_template_devnet"` - ClaimTodayQuestDevnet string `json:"claim_today_quest_devnet"` - MintNFTDevnet string `json:"mint_nft_devnet"` - LikeNFTDevnet string `json:"like_nft_devnet"` - UnlikeNFTDevnet string `json:"unlike_nft_devnet"` - VoteColorDevnet string `json:"vote_color_devnet"` - NewUsernameDevnet string `json:"new_username_devnet"` - ChangeUsernameDevnet string `json:"change_username_devnet"` - IncreaseDayDevnet string `json:"increase_day_devnet"` - JoinChainFactionDevnet string `json:"join_chain_faction_devnet"` - JoinFactionDevnet string `json:"join_faction_devnet"` - LeaveFactionDevnet string `json:"leave_faction_devnet"` - AddFactionTemplateDevnet string `json:"add_faction_template_devnet"` - RemoveFactionTemplateDevnet string `json:"remove_faction_template_devnet"` + PlacePixelDevnet string + PlaceExtraPixelsDevnet string + AddTemplateDevnet string + ClaimTodayQuestDevnet string + MintNFTDevnet string + LikeNFTDevnet string + UnlikeNFTDevnet string + VoteColorDevnet string + NewUsernameDevnet string + ChangeUsernameDevnet string + IncreaseDayDevnet string + JoinChainFactionDevnet string + JoinFactionDevnet string + LeaveFactionDevnet string + AddFactionTemplateDevnet string + RemoveFactionTemplateDevnet string } type WebSocketConfig struct { - ReadBufferSize int `json:"read_buffer_size"` - WriteBufferSize int `json:"write_buffer_size"` + ReadBufferSize int + WriteBufferSize int } type HttpConfig struct { - AllowOrigin []string `json:"allow_origin"` - AllowMethods []string `json:"allow_methods"` - AllowHeaders []string `json:"allow_headers"` + AllowOrigin []string + AllowMethods []string + AllowHeaders []string } type BackendConfig struct { - Host string `json:"host"` - Port int `json:"port"` - ConsumerPort int `json:"consumer_port"` - Scripts BackendScriptsConfig `json:"scripts"` - Production bool `json:"production"` - WebSocket WebSocketConfig `json:"websocket"` - Http HttpConfig `json:"http_config"` + Host string + Port int + ConsumerPort int + Scripts BackendScriptsConfig + Production bool + WebSocket WebSocketConfig + Http HttpConfig } -var DefaultBackendConfig = BackendConfig{ - Host: "localhost", - Port: 8080, - ConsumerPort: 8081, - Scripts: BackendScriptsConfig{ - PlacePixelDevnet: "../scripts/place_pixel.sh", - PlaceExtraPixelsDevnet: "../scripts/place_extra_pixels.sh", - AddTemplateDevnet: "../scripts/add_template.sh", - ClaimTodayQuestDevnet: "../scripts/claim_today_quest.sh", - MintNFTDevnet: "../scripts/mint_nft.sh", - LikeNFTDevnet: "../scripts/like_nft.sh", - UnlikeNFTDevnet: "../scripts/unlike_nft.sh", - VoteColorDevnet: "../scripts/vote_color.sh", - NewUsernameDevnet: "../scripts/new_username.sh", - ChangeUsernameDevnet: "../scripts/change_username.sh", - IncreaseDayDevnet: "../scripts/increase_day_index.sh", - JoinChainFactionDevnet: "../scripts/join_chain_faction.sh", - JoinFactionDevnet: "../scripts/join_faction.sh", - LeaveFactionDevnet: "../scripts/leave_faction.sh", - AddFactionTemplateDevnet: "../scripts/add_faction_template.sh", - RemoveFactionTemplateDevnet: "../scripts/remove_faction_template.sh", - }, - Production: false, - WebSocket: WebSocketConfig{ - ReadBufferSize: 1024, - WriteBufferSize: 1024, - }, - Http: HttpConfig{ - AllowOrigin: []string{"*"}, - AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, - AllowHeaders: []string{"Content-Type"}, - }, -} - -var DefaultBackendConfigPath = "./configs/backend.config.json" +func LoadBackendConfig() (*BackendConfig, error) { + backendPort, err := strconv.Atoi(os.Getenv("BACKEND_PORT")) + if err != nil { + return nil, fmt.Errorf("invalid BACKEND_PORT: %v", err) + } -func LoadBackendConfig(backendConfigPath string) (*BackendConfig, error) { - file, err := os.Open(backendConfigPath) + consumerPort, err := strconv.Atoi(os.Getenv("CONSUMER_PORT")) if err != nil { - return nil, err + return nil, fmt.Errorf("invalid BACKEND_PORT: %v", err) } - defer file.Close() - decoder := json.NewDecoder(file) - config := BackendConfig{} - err = decoder.Decode(&config) + production, err := strconv.ParseBool(os.Getenv("PRODUCTION")) if err != nil { - return nil, err + return nil, fmt.Errorf("invalid PRODUCTION mode: %v", err) } + config := BackendConfig{ + Host: os.Getenv("BACKEND_HOST"), + Port: backendPort, + ConsumerPort: consumerPort, + Scripts: BackendScriptsConfig{ + PlacePixelDevnet: "../scripts/place_pixel.sh", + PlaceExtraPixelsDevnet: "../scripts/place_extra_pixels.sh", + AddTemplateDevnet: "../scripts/add_template.sh", + ClaimTodayQuestDevnet: "../scripts/claim_today_quest.sh", + MintNFTDevnet: "../scripts/mint_nft.sh", + LikeNFTDevnet: "../scripts/like_nft.sh", + UnlikeNFTDevnet: "../scripts/unlike_nft.sh", + VoteColorDevnet: "../scripts/vote_color.sh", + NewUsernameDevnet: "../scripts/new_username.sh", + ChangeUsernameDevnet: "../scripts/change_username.sh", + IncreaseDayDevnet: "../scripts/increase_day_index.sh", + JoinChainFactionDevnet: "../scripts/join_chain_faction.sh", + JoinFactionDevnet: "../scripts/join_faction.sh", + LeaveFactionDevnet: "../scripts/leave_faction.sh", + AddFactionTemplateDevnet: "../scripts/add_faction_template.sh", + RemoveFactionTemplateDevnet: "../scripts/remove_faction_template.sh", + }, + Production: production, + WebSocket: WebSocketConfig{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + }, + Http: HttpConfig{ + AllowOrigin: []string{"*"}, + AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, + AllowHeaders: []string{"Content-Type"}, + }, + } return &config, nil } diff --git a/backend/config/database.go b/backend/config/database.go index 8e08889f..61f7fc35 100644 --- a/backend/config/database.go +++ b/backend/config/database.go @@ -1,39 +1,26 @@ package config import ( + "fmt" "os" "strconv" - "fmt" ) type RedisConfig struct { - Host string `json:"host"` - Port int `json:"port"` + Host string + Port int } type PostgresConfig struct { - Host string `json:"host"` - Port int `json:"port"` - User string `json:"user"` - Database string `json:"database"` + Host string + Port int + User string + Database string } type DatabaseConfig struct { - Redis RedisConfig `json:"redis"` - Postgres PostgresConfig `json:"postgres"` -} - -var DefaultDatabaseConfig = DatabaseConfig{ - Redis: RedisConfig{ - Host: "localhost", - Port: 6379, - }, - Postgres: PostgresConfig{ - Host: "localhost", - Port: 5432, - User: "afk-user", - Database: "afk-db", - }, + Redis RedisConfig + Postgres PostgresConfig } func LoadDatabaseConfig() (*DatabaseConfig, error) { @@ -49,8 +36,8 @@ func LoadDatabaseConfig() (*DatabaseConfig, error) { config := DatabaseConfig{ Redis: RedisConfig{ - Host: os.Getenv("REDIS_HOST"), - Port: redisPort, + Host: os.Getenv("REDIS_HOST"), + Port: redisPort, }, Postgres: PostgresConfig{ Host: os.Getenv("POSTGRES_HOST"), diff --git a/backend/configs/canvas.config.json b/backend/configs/canvas.config.json index f16844e6..0575dc73 100644 --- a/backend/configs/canvas.config.json +++ b/backend/configs/canvas.config.json @@ -37,7 +37,6 @@ "3F00EF", "1991F4", "5672E1", - "786EDE", "3C3C84", "C84CF5", "CDA3F5", diff --git a/backend/core/backend.go b/backend/core/backend.go index afe1fc3b..5ab79d23 100644 --- a/backend/core/backend.go +++ b/backend/core/backend.go @@ -3,6 +3,7 @@ package core import ( "fmt" "net/http" + "os" "sync" "github.com/gorilla/websocket" @@ -40,7 +41,7 @@ func (b *Backend) Start(port int) { func (b *Backend) GetBackendUrl() string { if b.BackendConfig.Production { - return "https://api.art-peace.net" + return os.Getenv("BACKEND_URL") } else { return fmt.Sprintf("http://%s:%d", b.BackendConfig.Host, b.BackendConfig.Port) } diff --git a/deno.lock b/deno.lock new file mode 100644 index 00000000..7d8ab2c0 --- /dev/null +++ b/deno.lock @@ -0,0 +1,80 @@ +{ + "version": "3", + "packages": { + "specifiers": { + "npm:@types/node": "npm:@types/node@18.16.19" + }, + "npm": { + "@types/node@18.16.19": { + "integrity": "sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA==", + "dependencies": {} + } + } + }, + "redirects": { + "https://esm.sh/starknet@5.14": "https://esm.sh/starknet@5.14.1", + "https://esm.sh/viem@1.4": "https://esm.sh/viem@1.4.2" + }, + "remote": { + "https://deno.land/std@0.150.0/media_types/_util.ts": "ce9b4fc4ba1c447dafab619055e20fd88236ca6bdd7834a21f98bd193c3fbfa1", + "https://deno.land/std@0.150.0/media_types/mod.ts": "2d4b6f32a087029272dc59e0a55ae3cc4d1b27b794ccf528e94b1925795b3118", + "https://deno.land/std@0.150.0/media_types/vendor/mime-db.v1.52.0.ts": "724cee25fa40f1a52d3937d6b4fbbfdd7791ff55e1b7ac08d9319d5632c7f5af", + "https://deno.land/x/xhr@0.3.0/mod.ts": "094aacd627fd9635cd942053bf8032b5223b909858fa9dc8ffa583752ff63b20", + "https://esm.sh/@apibara/indexer@0.3.1/starknet": "c5bc67974872c73e5da7afc8d9e2b9eb0b78b57f4207f3c6c5631269fb14147c", + "https://esm.sh/starknet@5.14.1": "28136a3b6937465d3d0d2b1eb6d8182c75652d5064e692ae458866836b28f203", + "https://esm.sh/v135/@apibara/indexer@0.3.1/denonext/starknet.js": "0bffde4519f613582e62143c1bdc63356f8bd043e309858d8795ee2363f2ffd1", + "https://esm.sh/v135/@noble/curves@1.0.0/denonext/_shortw_utils.js": "fafe045ca5a727e93221e9656f88a58253729fb0b7c3301c1238864f133488a6", + "https://esm.sh/v135/@noble/curves@1.0.0/denonext/abstract/curve.js": "8a113c300533a5b1dc0bd3d358a58a39ba20263ab537872d29881eacd0b76d45", + "https://esm.sh/v135/@noble/curves@1.0.0/denonext/abstract/hash-to-curve.js": "618017d3102aeb325f0d8b181a36878ec524b5059cf6cf537fb2c5aba37614d5", + "https://esm.sh/v135/@noble/curves@1.0.0/denonext/abstract/modular.js": "ae9c28dca5f0c1a57ed379a9b377bbca7b7432bd46ca5017a04609598dfd5625", + "https://esm.sh/v135/@noble/curves@1.0.0/denonext/abstract/poseidon.js": "44d8a3e0583da2ef1883c5270394845cb178c085bbf636215084643b59d241f2", + "https://esm.sh/v135/@noble/curves@1.0.0/denonext/abstract/utils.js": "e6bb1a3234f1a28b40866ab235d558a042d9764a693956efa0fc399b46753eac", + "https://esm.sh/v135/@noble/curves@1.0.0/denonext/abstract/weierstrass.js": "f7c1d4fb804251ccc2bd6db5aaa327f695fd591eccc0072a0345c4fdc0263454", + "https://esm.sh/v135/@noble/curves@1.0.0/denonext/secp256k1.js": "3967154ead515e56968b2b8aba1785c10e76c39577236b7751c915071c372b55", + "https://esm.sh/v135/@noble/curves@1.2.0/denonext/_shortw_utils.js": "47a889dd52d7b29e8bc0d641597239d0ae0b1f10a9ee3b114c3dbd273172e9af", + "https://esm.sh/v135/@noble/curves@1.2.0/denonext/abstract/curve.js": "d4ac165ea7e6b5e401112e5c91d38a258fc6fe1dc3afa1c391cc2e74141d2228", + "https://esm.sh/v135/@noble/curves@1.2.0/denonext/abstract/modular.js": "6d0fba13e486d0929f4ab81f7774d75b2254b934e0be7dd3fd96b959c0dd7600", + "https://esm.sh/v135/@noble/curves@1.2.0/denonext/abstract/poseidon.js": "ab1b97653828117af52678e8a62ef976b529fdc56359172639c1c8e9c1d5d895", + "https://esm.sh/v135/@noble/curves@1.2.0/denonext/abstract/utils.js": "33d6521fbb6756d058dff59616da1e55a55972b4848976c27e81c4b4bc36c0e4", + "https://esm.sh/v135/@noble/curves@1.2.0/denonext/abstract/weierstrass.js": "5cb85ed9ad826f7e623f0052a55a865ad957eab06c30697804157844e82416a9", + "https://esm.sh/v135/@noble/hashes@1.3.0/denonext/_assert.js": "71dd0f5dc02b25513b5626a31922758a28a33baa07a25c41632b98e5e0aae1b7", + "https://esm.sh/v135/@noble/hashes@1.3.0/denonext/_sha2.js": "4928b2144c1c82206cd7c6788b964d9cd28400e4900065077908ad66a09521b3", + "https://esm.sh/v135/@noble/hashes@1.3.0/denonext/crypto.js": "38ef199043a068b6b97d8f91f1e5991ff4843fe7cfed35588dd927defb3b1139", + "https://esm.sh/v135/@noble/hashes@1.3.0/denonext/hmac.js": "9d6daaea2ca7aaf07fce0a224a8e31b77951a57e81ffccdac31c451e28aebdbb", + "https://esm.sh/v135/@noble/hashes@1.3.0/denonext/sha256.js": "5483ef2fc7cddff45e92054a93a0790dac9d4df4ef12478684e20b11325a55f2", + "https://esm.sh/v135/@noble/hashes@1.3.0/denonext/sha3.js": "b8b819e832da73dd95711d014a6b5692a47c50098dbc37aad972fba025b9bb28", + "https://esm.sh/v135/@noble/hashes@1.3.0/denonext/utils.js": "c75dc36831fccde5833088b67254427dd99c526835804a7a9c7bf856e30a8330", + "https://esm.sh/v135/@noble/hashes@1.3.2/denonext/_assert.js": "8b3fcc3a8d18f25fded5ecb46250f2009f11e0586b0e1e33757d12b6d1b05591", + "https://esm.sh/v135/@noble/hashes@1.3.2/denonext/crypto.js": "3d09b6d143b1cb8ec95375586a7d9a30a02b60cb1de984ba374c779f8e6c877a", + "https://esm.sh/v135/@noble/hashes@1.3.2/denonext/hmac.js": "23f7c9bebfbb777754e776b3197005b866742ce752945bfca17bc3c3a59e0e21", + "https://esm.sh/v135/@noble/hashes@1.3.2/denonext/utils.js": "bbfe700df9ae51f477688035ec3dd96739f77fd2f50439a4c84bcbc1aa7c7d4a", + "https://esm.sh/v135/@noble/hashes@1.3.3/denonext/_assert.js": "f8882bd96e2a6d1834a445c5af97f927b1ba028f34963c8570568e33385c4419", + "https://esm.sh/v135/@noble/hashes@1.3.3/denonext/_sha2.js": "7b27807ccd3cf7c3b90ce23b17bc9c5d791a72e41dd2e01a4debd9727990bca9", + "https://esm.sh/v135/@noble/hashes@1.3.3/denonext/crypto.js": "cf6efbafcbb35e03bcb3a36cccd3d6d1f9bc4ba23f44a79551929a28c83e7901", + "https://esm.sh/v135/@noble/hashes@1.3.3/denonext/sha256.js": "762e0b0cbde1990fc905eb816d30cdc0cf7dd4c23d123408c6963294f124f97d", + "https://esm.sh/v135/@noble/hashes@1.3.3/denonext/sha3.js": "3765211a8eec7f75e4ad8f265c023ed372b16327f214133ce4fc64c3a1423404", + "https://esm.sh/v135/@noble/hashes@1.3.3/denonext/utils.js": "701831e12a7e656df467b62f929ac9536ababef1b9b7445c7f87512366ae3933", + "https://esm.sh/v135/@scure/base@1.1.5/denonext/base.mjs": "442a66c701330f27adff8b2fa6067308ff9a82b8e178c8482882ff4aa7dfee82", + "https://esm.sh/v135/@scure/starknet@0.3.0/denonext/starknet.mjs": "d50471d53849ef5de4018ca196ef69fb8f487016b83c36b4a9509291672abd6c", + "https://esm.sh/v135/abitype@0.9.3/denonext/abitype.mjs": "c34994f6b891b4dd14327365366fe144ff9dc1c4ad9e2f51f00fd1859ac08df2", + "https://esm.sh/v135/bufferutil@4.0.8/denonext/bufferutil.mjs": "60a4618cbd1a5cb24935c55590b793d4ecb33862357d32e1d4614a0bbb90947f", + "https://esm.sh/v135/isomorphic-fetch@3.0.0/denonext/isomorphic-fetch.mjs": "a791533fb7269baadd38e8e4a34a46a6cb70f5c89ea4e8f4b3e1e5ddd533d61f", + "https://esm.sh/v135/isomorphic-ws@5.0.0/denonext/isomorphic-ws.mjs": "9d8dcd29684101d1a7b9088ad6681da41967322755c6b4ac01a2b721314d2ac5", + "https://esm.sh/v135/lossless-json@2.0.11/denonext/lossless-json.mjs": "cb67043586fcaab9a862a403c35abc3c8485788ec59fd403565c7f550a932085", + "https://esm.sh/v135/micro-starknet@0.2.3/denonext/micro-starknet.mjs": "1d66a0f0a2ef5814082e7c00ee85ddc59d4fe2c7cb02c02f3272da2bd4689f0a", + "https://esm.sh/v135/node-gyp-build@4.6.1/denonext/node-gyp-build.mjs": "5d28b312f145a6cb2ec0dbdd80a7d34c0e0e6b5dcada65411d8bcff6c8991cc6", + "https://esm.sh/v135/pako@2.1.0/denonext/pako.mjs": "a96661a4528965146d092709c0566bbb9a6fbc04e21587adfae26d48b2b3d763", + "https://esm.sh/v135/starknet@5.14.1/denonext/starknet.mjs": "26128462d49e78d14adf881f1ad5fa6cd401d34cd5267cb551c94a084d2cd0dc", + "https://esm.sh/v135/starknet@5.24.3/denonext/starknet.mjs": "2728a4d3cefb2ae5e0292d590509eca79906286b8abf1523368224221fe38f2d", + "https://esm.sh/v135/url-join@4.0.1/denonext/url-join.mjs": "1d2b840f03b6a3aaaaaa56380ea7879740d376a1dd83511b5bcf8af6c4617e1e", + "https://esm.sh/v135/utf-8-validate@6.0.3/denonext/utf-8-validate.mjs": "410c48d66840e987e474a4849cd25829817415cedd25466280effb1287d05aa5", + "https://esm.sh/v135/viem@1.4.2/denonext/dist/esm/utils/ccip.js": "cd25f6c198cca8585152a88af07712ba5b384e7db7bf855dfcee061e8f730166", + "https://esm.sh/v135/viem@1.4.2/denonext/utils.js": "dc95864e31961e04e8d28ef982f0f88168f2e5d6366e95660649286b195ed266", + "https://esm.sh/v135/viem@1.4.2/denonext/viem.mjs": "1facf8ebb9e39a884abc3e0cb575e55c1e8d0805efe7560a295a21671adfa460", + "https://esm.sh/v135/whatwg-fetch@3.6.19/denonext/whatwg-fetch.mjs": "65c15a9d84dcbd90308975b351e180e43ed2849f0be2312e543202d4fbb8e335", + "https://esm.sh/v135/ws@8.14.2/denonext/ws.mjs": "e96033415d90efd20ce6bec551447fc2771a27e232d05689c5410582cf04ee17", + "https://esm.sh/v135/zod@3.22.4/denonext/zod.mjs": "660128af5d1e921745c4d452472d103d9f2fc5afa508bbf233b83d35a272ad67", + "https://esm.sh/v135/zod@3.23.5/denonext/zod.mjs": "f4e0c43e8ed12aac124a79022c6bb47db25c2a6acd1d83e00ee9caca1d512ca4", + "https://esm.sh/viem@1.4.2": "86d05b62a8b1b90980ac8e6c3f459b0bf0ebe8abc52763456998aa6cc65ee68f" + } +} diff --git a/onchain/cairo/.snfoundry_cache/.prev_tests_failed b/onchain/cairo/.snfoundry_cache/.prev_tests_failed index e69de29b..a15dd69e 100644 --- a/onchain/cairo/.snfoundry_cache/.prev_tests_failed +++ b/onchain/cairo/.snfoundry_cache/.prev_tests_failed @@ -0,0 +1,5 @@ +afk::social::deposit::tests::deposit_claim +afk::social::deposit::tests::claim_incorrect_gas_amount +afk::social::deposit::tests::deposit_claim_gas_fee +afk::tests::launchpad_tests::launchpad_tests::launchpad_integration +afk::tests::vault_tests::vault_test::test_mint_by_token diff --git a/onchain/cairo/src/defi/vault.cairo b/onchain/cairo/src/defi/vault.cairo index ed6ec8e1..bc426984 100644 --- a/onchain/cairo/src/defi/vault.cairo +++ b/onchain/cairo/src/defi/vault.cairo @@ -78,7 +78,7 @@ pub mod Vault { let caller = get_caller_address(); // Check if token valid - assert(self.is_token_permitted(token_address), 'Non permited token'); + assert(self.is_token_permitted(token_address), 'Non permitted token'); // Sent token to deposit let token_deposited = IERC20Dispatcher { contract_address: token_address }; @@ -136,7 +136,7 @@ pub mod Vault { ) { let caller = get_caller_address(); // Check if token valid - assert(self.is_token_permitted(token_address), 'Non permited token'); + assert(self.is_token_permitted(token_address), 'Non permitted token'); // Receive/burn token minted let token_mintable = IERC20MintableDispatcher { @@ -194,7 +194,7 @@ pub mod Vault { } fn get_token_ratio(ref self: ContractState, token_address: ContractAddress) -> u256 { - assert(self.is_token_permitted(token_address), 'Non permited token'); + assert(self.is_token_permitted(token_address), 'Non permitted token'); self.token_permitted.read(token_address).ratio_mint } diff --git a/onchain/cairo/src/interfaces/mod.cairo b/onchain/cairo/src/interfaces/mod.cairo index d117b42d..bd475752 100644 --- a/onchain/cairo/src/interfaces/mod.cairo +++ b/onchain/cairo/src/interfaces/mod.cairo @@ -2,5 +2,10 @@ pub mod interfaces { pub mod erc20; pub mod erc20_mintable; pub mod jediswap; + pub mod nfts; + pub mod pixel; + pub mod pixel_template; + pub mod quests; + pub mod username_store; pub mod vault; } diff --git a/onchain/cairo/src/interfaces/nfts.cairo b/onchain/cairo/src/interfaces/nfts.cairo new file mode 100644 index 00000000..f0cbc7db --- /dev/null +++ b/onchain/cairo/src/interfaces/nfts.cairo @@ -0,0 +1,58 @@ +#[derive(Drop, Serde)] +pub struct NFTMintParams { + pub position: u128, + pub width: u128, + pub height: u128, + pub name: felt252, +} + +#[derive(Drop, Copy, Serde, PartialEq, starknet::Store)] +pub struct NFTMetadata { + pub position: u128, + pub width: u128, + pub height: u128, + pub name: felt252, + pub image_hash: felt252, + pub block_number: u64, + pub day_index: u32, + pub minter: starknet::ContractAddress, +} + +#[starknet::interface] +pub trait ICanvasNFTStore { + // Returns the on-chain metadata of the NFT. + fn get_nft_metadata(self: @TContractState, token_id: u256) -> NFTMetadata; + fn get_nft_minter(self: @TContractState, token_id: u256) -> starknet::ContractAddress; + fn get_nft_image_hash(self: @TContractState, token_id: u256) -> felt252; + fn get_nft_day_index(self: @TContractState, token_id: u256) -> u32; + + // Returns the number of NFTs stored in the contract state. + fn get_nfts_count(self: @TContractState) -> u256; +} + +#[starknet::interface] +pub trait ICanvasNFTAdditional { + // Sets up the contract addresses + fn set_canvas_contract(ref self: TContractState, canvas_contract: starknet::ContractAddress); + // Mint a new NFT called by the ArtPeaceNFTMinter contract. + fn mint(ref self: TContractState, metadata: NFTMetadata, receiver: starknet::ContractAddress); + // Change the base uri of the NFTs. + fn set_base_uri(ref self: TContractState, base_uri: ByteArray); +} + +#[starknet::interface] +pub trait IArtPeaceNFTMinter { + // Sets up the contract addresses + fn add_nft_contract(ref self: TContractState, nft_contract: starknet::ContractAddress); + // Mints a new NFT from the canvas using init params, and returns the token ID. + fn mint_nft(ref self: TContractState, mint_params: NFTMintParams); + // Change the base uri of the NFTs. + fn set_nft_base_uri(ref self: TContractState, base_uri: ByteArray); +} + +#[starknet::interface] +pub trait ICanvasNFTLikeAndUnlike { + fn like_nft(ref self: TContractState, token_id: u256); + fn unlike_nft(ref self: TContractState, token_id: u256); +} + diff --git a/onchain/cairo/src/interfaces/pixel.cairo b/onchain/cairo/src/interfaces/pixel.cairo new file mode 100644 index 00000000..7f8313ed --- /dev/null +++ b/onchain/cairo/src/interfaces/pixel.cairo @@ -0,0 +1,357 @@ +use starknet::ContractAddress; + + +#[derive(Drop, Serde, starknet::Store)] +pub struct Pixel { + // Color index in the palette + pub color: u8, + // The person that placed the pixel + pub owner: starknet::ContractAddress, +} + +#[derive(Drop, Serde, starknet::Store)] +pub struct Faction { + pub name: felt252, + pub leader: starknet::ContractAddress, + pub joinable: bool, + pub allocation: u32 +} + +#[derive(Drop, Serde, starknet::Store)] +pub struct ChainFaction { + pub name: felt252, +} + +#[derive(Drop, Serde, starknet::Store)] +pub struct MemberMetadata { + pub member_placed_time: u64, + pub member_pixels: u32 +} + +// TODO: Tests for all +// TODO: Split into components : existing w/ canvas and user info, quests, stats, etc. +#[starknet::interface] +pub trait IArtPeace { + // Get canvas info + // fn get_pixel(self: @TContractState, pos: u128) -> Pixel; + // fn get_pixel_color(self: @TContractState, pos: u128) -> u8; + // fn get_pixel_owner(self: @TContractState, pos: u128) -> starknet::ContractAddress; + // fn get_pixel_xy(self: @TContractState, x: u128, y: u128) -> Pixel; + fn get_width(self: @TContractState) -> u128; + fn get_height(self: @TContractState) -> u128; + fn get_total_pixels(self: @TContractState) -> u128; + + // Assertion helpers + fn check_game_running(self: @TContractState); + fn check_valid_pixel(self: @TContractState, pos: u128, color: u8); + fn check_timing(self: @TContractState, now: u64); + + // Place pixels on the canvas + fn place_pixel(ref self: TContractState, pos: u128, color: u8, now: u64); + fn place_pixel_xy(ref self: TContractState, x: u128, y: u128, color: u8, now: u64); + fn place_pixel_blocktime(ref self: TContractState, pos: u128, color: u8); + fn place_extra_pixels( + ref self: TContractState, positions: Span, colors: Span, now: u64 + ); + + // Get placement info + fn get_last_placed_time(self: @TContractState) -> u64; + fn get_user_last_placed_time(self: @TContractState, user: starknet::ContractAddress) -> u64; + fn get_time_between_pixels(self: @TContractState) -> u64; + fn get_extra_pixels_count(self: @TContractState) -> u32; + fn get_user_extra_pixels_count(self: @TContractState, user: starknet::ContractAddress) -> u32; + + // Faction stuff + fn get_factions_count(self: @TContractState) -> u32; + fn get_faction(self: @TContractState, faction_id: u32) -> Faction; + fn get_faction_leader(self: @TContractState, faction_id: u32) -> starknet::ContractAddress; + fn init_faction( + ref self: TContractState, + name: felt252, + leader: starknet::ContractAddress, + joinable: bool, + allocation: u32 + ); + fn change_faction_leader( + ref self: TContractState, faction_id: u32, new_leader: starknet::ContractAddress + ); + fn init_chain_faction(ref self: TContractState, name: felt252); + fn join_faction(ref self: TContractState, faction_id: u32); + // TODO: fn leave_faction(ref self: TContractState); + fn join_chain_faction(ref self: TContractState, faction_id: u32); + fn get_user_faction(self: @TContractState, user: starknet::ContractAddress) -> u32; + fn get_user_chain_faction(self: @TContractState, user: starknet::ContractAddress) -> u32; + fn get_user_faction_members_pixels( + self: @TContractState, user: starknet::ContractAddress, now: u64 + ) -> u32; + fn get_chain_faction_members_pixels( + self: @TContractState, user: starknet::ContractAddress, now: u64 + ) -> u32; + + // Get color info + fn get_color_count(self: @TContractState) -> u8; + fn get_colors(self: @TContractState) -> Array; + + // Color voting + fn vote_color(ref self: TContractState, color: u8); + fn get_color_votes(self: @TContractState, color: u8) -> u32; + fn get_user_vote(self: @TContractState, user: starknet::ContractAddress, day: u32) -> u8; + fn get_votable_colors(self: @TContractState) -> Array; + + // Get timing info + fn get_creation_time(self: @TContractState) -> u64; + fn get_end_time(self: @TContractState) -> u64; + fn get_day(self: @TContractState) -> u32; + + // Start a new day + fn increase_day_index(ref self: TContractState); + + // Get quest info + fn get_daily_quests_count(self: @TContractState) -> u32; + fn get_daily_quest( + self: @TContractState, day_index: u32, quest_id: u32 + ) -> starknet::ContractAddress; + fn get_days_quests(self: @TContractState, day_index: u32) -> Span; + fn get_today_quests(self: @TContractState) -> Span; + + fn get_main_quest_count(self: @TContractState) -> u32; + fn get_main_quest(self: @TContractState, quest_id: u32) -> starknet::ContractAddress; + fn get_main_quests(self: @TContractState) -> Span; + + // Quests + fn add_daily_quests( + ref self: TContractState, day_index: u32, quests: Span + ); + fn add_main_quests(ref self: TContractState, quests: Span); + fn claim_today_quest(ref self: TContractState, quest_id: u32, calldata: Span); + fn claim_main_quest(ref self: TContractState, quest_id: u32, calldata: Span); + + // NFT info + fn get_nft_contract(self: @TContractState) -> starknet::ContractAddress; + + // Templates + fn add_faction_template( + ref self: TContractState, + template_metadata: afk::interfaces::pixel_template::FactionTemplateMetadata + ); + // fn add_faction_template( + // ref self: TContractState, + // template_metadata: afk::pixel::templates::interfaces::FactionTemplateMetadata + // ); + fn remove_faction_template(ref self: TContractState, template_id: u32); + // fn add_chain_faction_template( + // ref self: TContractState, + // template_metadata: afk::pixel::templates::interfaces::FactionTemplateMetadata + // ); + fn add_chain_faction_template( + ref self: TContractState, + template_metadata: afk::interfaces::pixel_template::FactionTemplateMetadata + ); + fn remove_chain_faction_template(ref self: TContractState, template_id: u32); + + // Stats + fn get_user_pixels_placed(self: @TContractState, user: starknet::ContractAddress) -> u32; + fn get_user_pixels_placed_day( + self: @TContractState, user: starknet::ContractAddress, day: u32 + ) -> u32; + fn get_user_pixels_placed_color( + self: @TContractState, user: starknet::ContractAddress, color: u8 + ) -> u32; + fn get_user_pixels_placed_day_color( + self: @TContractState, user: starknet::ContractAddress, day: u32, color: u8 + ) -> u32; +} + + +#[derive(Drop, starknet::Event)] +pub struct ColorAdded { + #[key] + pub color_key: u8, + pub color: u32, +} + +#[derive(Drop, starknet::Event)] +pub struct NewDay { + #[key] + pub day_index: u32, + pub start_time: u64, +} + +#[derive(Drop, starknet::Event)] +pub struct CanvasScaled { + pub old_width: u128, + pub new_width: u128, + pub old_height: u128, + pub new_height: u128 +} + +#[derive(Drop, starknet::Event)] +pub struct PixelPlaced { + #[key] + pub placed_by: ContractAddress, + #[key] + pub pos: u128, + #[key] + pub day: u32, + pub color: u8, +} + +#[derive(Drop, starknet::Event)] +pub struct BasicPixelPlaced { + #[key] + pub placed_by: ContractAddress, + pub timestamp: u64, +} + +#[derive(Drop, starknet::Event)] +pub struct FactionPixelsPlaced { + #[key] + pub user: ContractAddress, + pub placed_time: u64, + pub member_pixels: u32, +} + +#[derive(Drop, starknet::Event)] +pub struct ChainFactionPixelsPlaced { + #[key] + pub user: ContractAddress, + pub placed_time: u64, + pub member_pixels: u32, +} + +#[derive(Drop, starknet::Event)] +pub struct ExtraPixelsPlaced { + #[key] + pub placed_by: ContractAddress, + pub extra_pixels: u32, +} + +#[derive(Drop, starknet::Event)] +pub struct DailyQuestClaimed { + #[key] + pub day_index: u32, + #[key] + pub quest_id: u32, + #[key] + pub user: ContractAddress, + pub reward: u32, + pub calldata: Span, +} + +#[derive(Drop, starknet::Event)] +pub struct MainQuestClaimed { + #[key] + pub quest_id: u32, + #[key] + pub user: ContractAddress, + pub reward: u32, + pub calldata: Span, +} + +#[derive(Drop, starknet::Event)] +pub struct VoteColor { + #[key] + pub voted_by: ContractAddress, + #[key] + pub day: u32, + #[key] + pub color: u8, +} + +#[derive(Drop, starknet::Event)] +pub struct VotableColorAdded { + #[key] + pub day: u32, + #[key] + pub color_key: u8, + pub color: u32, +} + +#[derive(Drop, starknet::Event)] +pub struct FactionCreated { + #[key] + pub faction_id: u32, + pub name: felt252, + pub leader: ContractAddress, + pub joinable: bool, + pub allocation: u32, +} + +#[derive(Drop, starknet::Event)] +pub struct FactionLeaderChanged { + #[key] + pub faction_id: u32, + pub new_leader: ContractAddress, +} + +#[derive(Drop, starknet::Event)] +pub struct ChainFactionCreated { + #[key] + pub faction_id: u32, + pub name: felt252, +} + +#[derive(Drop, starknet::Event)] +pub struct FactionJoined { + #[key] + pub faction_id: u32, + #[key] + pub user: ContractAddress, +} + +#[derive(Drop, starknet::Event)] +pub struct FactionLeft { + #[key] + pub faction_id: u32, + #[key] + pub user: ContractAddress, +} + +#[derive(Drop, starknet::Event)] +pub struct ChainFactionJoined { + #[key] + pub faction_id: u32, + #[key] + pub user: ContractAddress, +} + +#[derive(Drop, starknet::Event)] +pub struct FactionTemplateAdded { + #[key] + pub template_id: u32, + pub template_metadata: afk::interfaces::pixel_template::FactionTemplateMetadata, +} + +#[derive(Drop, starknet::Event)] +pub struct FactionTemplateRemoved { + #[key] + pub template_id: u32, +} + +#[derive(Drop, starknet::Event)] +pub struct ChainFactionTemplateAdded { + #[key] + pub template_id: u32, + pub template_metadata: afk::interfaces::pixel_template::FactionTemplateMetadata, +} + +#[derive(Drop, starknet::Event)] +pub struct ChainFactionTemplateRemoved { + #[key] + pub template_id: u32, +} + +#[derive(Drop, Serde)] +pub struct InitParams { + pub host: ContractAddress, + pub canvas_width: u128, + pub canvas_height: u128, + pub time_between_pixels: u64, + pub color_palette: Array, + pub votable_colors: Array, + pub daily_new_colors_count: u32, + pub start_time: u64, + pub end_time: u64, + pub daily_quests_count: u32, + pub devmode: bool, +} diff --git a/onchain/cairo/src/interfaces/pixel_template.cairo b/onchain/cairo/src/interfaces/pixel_template.cairo new file mode 100644 index 00000000..b78bb15d --- /dev/null +++ b/onchain/cairo/src/interfaces/pixel_template.cairo @@ -0,0 +1,49 @@ +use starknet::ContractAddress; + +#[derive(Drop, Copy, Serde, starknet::Store)] +pub struct FactionTemplateMetadata { + pub faction_id: u32, + pub hash: felt252, + pub position: u128, + pub width: u128, + pub height: u128, +} + +#[derive(Drop, Copy, Serde, starknet::Store)] +pub struct TemplateMetadata { + pub hash: felt252, + pub name: felt252, + pub position: u128, + pub width: u128, + pub height: u128, + pub reward: u256, + pub reward_token: ContractAddress, + pub creator: ContractAddress +} + +#[starknet::interface] +pub trait ITemplateStore { + // Returns the number of templates stored in the contract state. + fn get_templates_count(self: @TContractState) -> u32; + // Returns the template metadata stored in the contract state. + fn get_template(self: @TContractState, template_id: u32) -> TemplateMetadata; + // Returns the template image hash stored in the contract state. + fn get_template_hash(self: @TContractState, template_id: u32) -> felt252; + // Stores a new template image into the contract state w/ metadata. + // If the reward/token are set, then the contract escrows the reward for the template. + fn add_template(ref self: TContractState, template_metadata: TemplateMetadata); + // Returns whether the template is complete. + fn is_template_complete(self: @TContractState, template_id: u32) -> bool; +} + +#[starknet::interface] +pub trait ITemplateVerifier { + // Verifies the template is complete, and if so, sets the template as complete. + // If there was a reward escrowed, it is transferred to the builders. + // Passed template_image contains the full image, and is used to verify the template. + fn complete_template(ref self: TContractState, template_id: u32, template_image: Span); + fn complete_template_with_rewards( + ref self: TContractState, template_id: u32, template_image: Span + ); + fn compute_template_hash(self: @TContractState, template: Span) -> felt252; +} diff --git a/onchain/cairo/src/interfaces/quests.cairo b/onchain/cairo/src/interfaces/quests.cairo new file mode 100644 index 00000000..4be9f6bc --- /dev/null +++ b/onchain/cairo/src/interfaces/quests.cairo @@ -0,0 +1,51 @@ +use starknet::ContractAddress; + +#[starknet::interface] +pub trait IQuest { + // Return the reward for the quest. + fn get_reward(self: @TContractState) -> u32; + // Return if the user can claim the quest. + fn is_claimable(self: @TContractState, user: ContractAddress, calldata: Span) -> bool; + // Claim the quest. + fn claim(ref self: TContractState, user: ContractAddress, calldata: Span) -> u32; +} + +#[starknet::interface] +pub trait IAuthorityQuest { + fn is_claimed(self: @TContractState, user: ContractAddress) -> bool; + fn mark_claimable(ref self: TContractState, calldata: Span); +} + +#[starknet::interface] +pub trait IPixelQuest { + fn is_claimed(self: @TContractState, user: starknet::ContractAddress) -> bool; + fn get_pixels_needed(self: @TContractState) -> u32; + fn is_daily(self: @TContractState) -> bool; + fn claim_day(self: @TContractState) -> u32; + fn is_color(self: @TContractState) -> bool; + fn color(self: @TContractState) -> u8; +} + +#[starknet::interface] +pub trait IRainbowQuest { + fn is_claimed(self: @TContractState, user: starknet::ContractAddress) -> bool; +} + + +#[starknet::interface] +pub trait IFactionQuest { + fn is_claimed(self: @TContractState, user: starknet::ContractAddress) -> bool; +} + +#[starknet::interface] +pub trait IUnruggableQuest { + fn is_claimed(self: @TContractState, user: starknet::ContractAddress) -> bool; +} + +#[starknet::interface] +pub trait IUnruggableMemecoin { + // Returns the owner of the unruggable memecoin + fn owner(self: @TState) -> ContractAddress; + // Checks whether token has launched + fn is_launched(self: @TState) -> bool; +} diff --git a/onchain/cairo/src/interfaces/username_store.cairo b/onchain/cairo/src/interfaces/username_store.cairo new file mode 100644 index 00000000..168a27ab --- /dev/null +++ b/onchain/cairo/src/interfaces/username_store.cairo @@ -0,0 +1,9 @@ +use starknet::ContractAddress; + +#[starknet::interface] +pub trait IUsernameStore { + fn claim_username(ref self: TContractState, key: felt252); + fn change_username(ref self: TContractState, new_username: felt252); + fn get_username(self: @TContractState, address: ContractAddress) -> felt252; + fn get_username_address(self: @TContractState, key: felt252) -> ContractAddress; +} diff --git a/onchain/cairo/src/launchpad/launchpad.cairo b/onchain/cairo/src/launchpad/launchpad.cairo index c65b1eb6..e1358b84 100644 --- a/onchain/cairo/src/launchpad/launchpad.cairo +++ b/onchain/cairo/src/launchpad/launchpad.cairo @@ -396,16 +396,6 @@ mod LaunchpadMarketplace { self._launch_token(coin_address, caller); } - // Launch liquidity if threshold ok - fn launch_liquidity(ref self: ContractState, coin_address: ContractAddress) { - let pool = self.launched_coins.read(coin_address); - - assert!(pool.liquidity_raised >= pool.threshold_liquidity, "no threshold raised"); - assert!(pool.is_liquidity_launch == false, "liquidity already launch"); - - self._add_liquidity(coin_address, SupportedExchanges::Jediswap); - } - // Buy coin by quote amount // Get amount of coin receive based on token IN @@ -508,7 +498,22 @@ mod LaunchpadMarketplace { // println!("amount to buy {:?}", amount); // println!("total_price {:?}", total_price); - // Update share and key stats + // Change the Stats of pool: + // Liquidity raised + // Available supply + // Token holded + pool_coin.liquidity_raised = pool_coin.liquidity_raised + total_price; + // pool_coin.total_supply += amount; + pool_coin.token_holded += amount; + pool_coin.price = total_price; + + if amount > pool_coin.available_supply { + pool_coin.available_supply = 0; + } else { + pool_coin.available_supply -= amount; + } + + // Update share and coin stats for an user let mut old_share = self.shares_by_users.read((get_caller_address(), coin_address)); // println!("old_share {:?}", old_share.owner); @@ -533,14 +538,7 @@ mod LaunchpadMarketplace { } // pool_coin.price = total_price; // pool_coin.price = total_price / amount; - pool_coin.liquidity_raised = pool_coin.liquidity_raised + total_price; - // pool_coin.total_supply += amount; - pool_coin.token_holded += amount; - if amount > pool_coin.available_supply { - pool_coin.available_supply = 0; - } else { - pool_coin.available_supply -= amount; - } + // pool_coin.available_supply-=amount; // TODO // ENABLE if direct launch coin @@ -638,18 +636,16 @@ mod LaunchpadMarketplace { assert!(old_pool.total_supply >= quote_amount, "above supply"); // TODO erc20 token transfer - // let token = old_pool.token_quote.clone(); let total_supply = old_pool.total_supply; let token_quote = old_pool.token_quote.clone(); let quote_token_address = token_quote.token_address.clone(); - // let mut amount = self - // ._get_amount_by_type_of_coin_or_quote(coin_address, quote_amount, false, true); - // TODO fix this function let mut amount = self ._get_coin_amount_by_quote_amount(coin_address, quote_amount, true); + assert!(share_user.amount_owned >= amount, "above supply"); + let mut total_price = quote_amount.clone(); // println!("amount {:?}", amount); // println!("quote_amount {:?}", quote_amount); @@ -665,8 +661,7 @@ mod LaunchpadMarketplace { old_pool.liquidity_raised >= quote_amount, "pool_update.liquidity_raised <= quote_amount" ); - // assert!( old_pool.liquidity_raised >= quote_amount, "pool_update.liquidity_raised <= - // quote_amount"); + // assert!( old_pool.liquidity_raised >= quote_amount, "pool_update.liquidity_raised <= quote_amount"); let old_price = old_pool.price.clone(); @@ -684,32 +679,31 @@ mod LaunchpadMarketplace { ); // TODO fix amount owned and sellable. - // Update share user key - // share_user.amount_owned -= amount; - // share_user.amount_sell += amount; + // Update share user coin + share_user.amount_owned -= amount; + share_user.amount_sell += amount; // Transfer to Liquidity, Creator and Protocol // println!("contract_balance {}", contract_balance); // println!("transfer creator fee {}", amount_creator_fee.clone()); // println!("transfer liquidity {}", remain_liquidity.clone()); - // erc20.transfer(get_caller_address(), remain_liquidity); + erc20.transfer(get_caller_address(), remain_liquidity); // // println!("transfer protocol fee {}", amount_protocol_fee.clone()); - // erc20.transfer(self.protocol_fee_destination.read(), amount_protocol_fee); + erc20.transfer(self.protocol_fee_destination.read(), amount_protocol_fee); // TODO sell coin if it's already sendable and transferable // ENABLE if direct launch coin // let memecoin = IERC20Dispatcher { contract_address: coin_address }; // memecoin.transfer_from(get_caller_address(), get_contract_address(), amount); - // pool_update.price = total_price; - - // key.total_supply -= amount; // TODO check reetrancy guard // TODO finish update state - // pool_update.price = total_price; - // pool_update.total_supply = pool_update.total_supply - amount; - // pool_update.liquidity_raised = pool_update.liquidity_raised - remain_liquidity; + pool_update.price = total_price; + pool_update.liquidity_raised = pool_update.liquidity_raised - total_price; + pool_update.token_holded -= amount; + pool_update.available_supply += amount; + self .shares_by_users .write((get_caller_address(), coin_address.clone()), share_user.clone()); @@ -730,7 +724,21 @@ mod LaunchpadMarketplace { ); } + + // TODO finish check + // Launch liquidity if threshold ok + fn launch_liquidity(ref self: ContractState, coin_address: ContractAddress) { + let pool = self.launched_coins.read(coin_address); + + assert!(pool.liquidity_raised >= pool.threshold_liquidity, "no threshold raised"); + assert!(pool.is_liquidity_launch == false, "liquidity already launch"); + + self._add_liquidity(coin_address, SupportedExchanges::Jediswap); + } + // TODO Finish this function + // Claim coin if liquidity is sent + // Check and modify the share of user fn claim_coin_buy(ref self: ContractState, coin_address: ContractAddress, amount: u256) {} diff --git a/onchain/cairo/src/lib.cairo b/onchain/cairo/src/lib.cairo index 9498fc5f..7bbf269f 100644 --- a/onchain/cairo/src/lib.cairo +++ b/onchain/cairo/src/lib.cairo @@ -7,7 +7,18 @@ pub mod social; pub mod utils; pub mod quests { pub mod factory; + pub mod authority_quest; + pub mod chain_faction_quest; + pub mod faction_quest; + pub mod hodl_quest; + pub mod nft_quest; + pub mod pixel_quest; + pub mod rainbow_quest; pub mod tap; + pub mod template_quest; + pub mod unruggable_quest; + pub mod username_quest; + pub mod vote_quest; } pub mod interfaces { @@ -15,6 +26,11 @@ pub mod interfaces { pub mod erc20_mintable; pub mod jediswap; pub mod quest; + pub mod nfts; + pub mod pixel; + pub mod pixel_template; + pub mod quests; + pub mod username_store; pub mod vault; } @@ -50,6 +66,23 @@ pub mod tokens { pub mod token; } +// TODO upgrade to correct OZ version +pub mod nfts { + pub mod canvas_nft; + pub mod component; +} + + +pub mod templates { + pub mod template; +} +pub mod pixel { + pub mod art_peace; +// pub mod templates; +// use art_peace::ArtPeace; + +} + #[cfg(test)] pub mod tests { pub mod identity_tests; diff --git a/onchain/cairo/src/nfts.cairo b/onchain/cairo/src/nfts.cairo new file mode 100644 index 00000000..1b48ba08 --- /dev/null +++ b/onchain/cairo/src/nfts.cairo @@ -0,0 +1 @@ +pub mod nfts; diff --git a/onchain/cairo/src/nfts/canvas_nft.cairo b/onchain/cairo/src/nfts/canvas_nft.cairo new file mode 100644 index 00000000..3d3a5710 --- /dev/null +++ b/onchain/cairo/src/nfts/canvas_nft.cairo @@ -0,0 +1,148 @@ +#[starknet::contract] +mod CanvasNFT { + use afk::interfaces::nfts::ICanvasNFTStore; + use afk::interfaces::nfts::{ICanvasNFTAdditional, ICanvasNFTLikeAndUnlike, NFTMetadata}; + use afk::nfts::component::CanvasNFTStoreComponent::CanvasNFTMinted; + use afk::nfts::component::CanvasNFTStoreComponent; + use openzeppelin::introspection::src5::SRC5Component; + // use openzeppelin::token::erc721::ERC721Component; + use openzeppelin::token::erc721::interface::IERC721Metadata; + use openzeppelin::token::erc721::{ERC721Component, ERC721HooksEmptyImpl}; + + use starknet::ContractAddress; + + component!(path: ERC721Component, storage: erc721, event: ERC721Event); + component!(path: SRC5Component, storage: src5, event: SRC5Event); + component!(path: CanvasNFTStoreComponent, storage: nfts, event: NFTEvent); + + #[abi(embed_v0)] + impl ERC721Impl = ERC721Component::ERC721Impl; + + #[abi(embed_v0)] + impl ERC721MetadataCamelOnly = + ERC721Component::ERC721MetadataCamelOnlyImpl; + #[abi(embed_v0)] + impl SRC5Impl = SRC5Component::SRC5Impl; + #[abi(embed_v0)] + impl CanvasNFTStoreImpl = + CanvasNFTStoreComponent::CanvasNFTStoreImpl; + + impl InternalImpl = ERC721Component::InternalImpl; + + #[storage] + struct Storage { + art_peace: ContractAddress, + #[substorage(v0)] + erc721: ERC721Component::Storage, + #[substorage(v0)] + src5: SRC5Component::Storage, + #[substorage(v0)] + nfts: CanvasNFTStoreComponent::Storage, + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + ERC721Event: ERC721Component::Event, + #[flat] + SRC5Event: SRC5Component::Event, + #[flat] + NFTEvent: CanvasNFTStoreComponent::Event, + NFTLiked: NFTLiked, + NFTUnliked: NFTUnliked, + } + + #[derive(Drop, starknet::Event)] + struct NFTLiked { + #[key] + token_id: u256, + #[key] + user_address: ContractAddress + } + + #[derive(Drop, starknet::Event)] + struct NFTUnliked { + #[key] + token_id: u256, + #[key] + user_address: ContractAddress + } + + + #[constructor] + fn constructor(ref self: ContractState, name: ByteArray, symbol: ByteArray) { + let base_uri = "https://api.art-peace.net/nft-meta/nft-"; + self.erc721.initializer(name, symbol, base_uri); + } + + #[abi(embed_v0)] + impl ERC721Metadata of IERC721Metadata { + fn name(self: @ContractState) -> ByteArray { + self.erc721.ERC721_name.read() + } + + fn symbol(self: @ContractState) -> ByteArray { + self.erc721.ERC721_symbol.read() + } + + fn token_uri(self: @ContractState, token_id: u256) -> ByteArray { + assert(self.erc721._exists(token_id), 'Token does not exist'); + let base_uri = self.erc721._base_uri(); + if base_uri.len() == 0 { + return ""; + } else { + return format!("{}{}.json", base_uri, token_id); + } + } + } + + #[abi(embed_v0)] + impl CanvasNFTAdditional of ICanvasNFTAdditional { + fn set_canvas_contract(ref self: ContractState, canvas_contract: ContractAddress) { + let zero_address = starknet::contract_address_const::<0>(); + assert(self.art_peace.read() == zero_address, 'ArtPeace contract already set'); + self.art_peace.write(canvas_contract); + } + + fn mint(ref self: ContractState, metadata: NFTMetadata, receiver: ContractAddress) { + assert( + self.art_peace.read() == starknet::get_caller_address(), + 'Only ArtPeace contract can mint' + ); + let token_id = self.nfts.get_nfts_count(); + self.nfts.nfts_data.write(token_id, metadata); + self.erc721._mint(receiver, token_id); + self.nfts.nfts_count.write(token_id + 1); + self.nfts.emit(CanvasNFTMinted { token_id, metadata }); + } + + fn set_base_uri(ref self: ContractState, base_uri: ByteArray) { + assert( + self.art_peace.read() == starknet::get_caller_address(), + 'Only ArtPeace can set base uri' + ); + self.erc721._set_base_uri(base_uri); + } + } + + #[abi(embed_v0)] + impl CanvasNFTLikeAndUnlike of ICanvasNFTLikeAndUnlike { + fn like_nft(ref self: ContractState, token_id: u256) { + assert(token_id < self.get_nfts_count(), 'NFT Does not Exist in the Store'); + + self + .emit( + NFTLiked { user_address: starknet::get_caller_address(), token_id: token_id } + ); + } + + fn unlike_nft(ref self: ContractState, token_id: u256) { + assert(token_id < self.get_nfts_count(), 'NFT Does not Exist in the Store'); + self + .emit( + NFTUnliked { user_address: starknet::get_caller_address(), token_id: token_id } + ); + } + } +} diff --git a/onchain/cairo/src/nfts/component.cairo b/onchain/cairo/src/nfts/component.cairo new file mode 100644 index 00000000..346dc8e3 --- /dev/null +++ b/onchain/cairo/src/nfts/component.cairo @@ -0,0 +1,54 @@ +#[starknet::component] +pub mod CanvasNFTStoreComponent { + use afk::interfaces::nfts::{ICanvasNFTStore, NFTMetadata}; + + #[storage] + struct Storage { + nfts_count: u256, + // Map: nft's token_id -> nft's metadata + nfts_data: LegacyMap::, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + CanvasNFTMinted: CanvasNFTMinted, + } + + #[derive(Drop, starknet::Event)] + pub struct CanvasNFTMinted { + #[key] + pub token_id: u256, + pub metadata: NFTMetadata, + } + + #[embeddable_as(CanvasNFTStoreImpl)] + impl CanvasNFTStore< + TContractState, +HasComponent + > of ICanvasNFTStore> { + fn get_nfts_count(self: @ComponentState) -> u256 { + return self.nfts_count.read(); + } + + fn get_nft_metadata(self: @ComponentState, token_id: u256) -> NFTMetadata { + return self.nfts_data.read(token_id); + } + + fn get_nft_minter( + self: @ComponentState, token_id: u256 + ) -> starknet::ContractAddress { + let metadata: NFTMetadata = self.nfts_data.read(token_id); + return metadata.minter; + } + + fn get_nft_day_index(self: @ComponentState, token_id: u256) -> u32 { + let metadata: NFTMetadata = self.nfts_data.read(token_id); + return metadata.day_index; + } + + fn get_nft_image_hash(self: @ComponentState, token_id: u256) -> felt252 { + let metadata: NFTMetadata = self.nfts_data.read(token_id); + return metadata.image_hash; + } + } +} diff --git a/onchain/cairo/src/nfts/interfaces.cairo b/onchain/cairo/src/nfts/interfaces.cairo new file mode 100644 index 00000000..f0cbc7db --- /dev/null +++ b/onchain/cairo/src/nfts/interfaces.cairo @@ -0,0 +1,58 @@ +#[derive(Drop, Serde)] +pub struct NFTMintParams { + pub position: u128, + pub width: u128, + pub height: u128, + pub name: felt252, +} + +#[derive(Drop, Copy, Serde, PartialEq, starknet::Store)] +pub struct NFTMetadata { + pub position: u128, + pub width: u128, + pub height: u128, + pub name: felt252, + pub image_hash: felt252, + pub block_number: u64, + pub day_index: u32, + pub minter: starknet::ContractAddress, +} + +#[starknet::interface] +pub trait ICanvasNFTStore { + // Returns the on-chain metadata of the NFT. + fn get_nft_metadata(self: @TContractState, token_id: u256) -> NFTMetadata; + fn get_nft_minter(self: @TContractState, token_id: u256) -> starknet::ContractAddress; + fn get_nft_image_hash(self: @TContractState, token_id: u256) -> felt252; + fn get_nft_day_index(self: @TContractState, token_id: u256) -> u32; + + // Returns the number of NFTs stored in the contract state. + fn get_nfts_count(self: @TContractState) -> u256; +} + +#[starknet::interface] +pub trait ICanvasNFTAdditional { + // Sets up the contract addresses + fn set_canvas_contract(ref self: TContractState, canvas_contract: starknet::ContractAddress); + // Mint a new NFT called by the ArtPeaceNFTMinter contract. + fn mint(ref self: TContractState, metadata: NFTMetadata, receiver: starknet::ContractAddress); + // Change the base uri of the NFTs. + fn set_base_uri(ref self: TContractState, base_uri: ByteArray); +} + +#[starknet::interface] +pub trait IArtPeaceNFTMinter { + // Sets up the contract addresses + fn add_nft_contract(ref self: TContractState, nft_contract: starknet::ContractAddress); + // Mints a new NFT from the canvas using init params, and returns the token ID. + fn mint_nft(ref self: TContractState, mint_params: NFTMintParams); + // Change the base uri of the NFTs. + fn set_nft_base_uri(ref self: TContractState, base_uri: ByteArray); +} + +#[starknet::interface] +pub trait ICanvasNFTLikeAndUnlike { + fn like_nft(ref self: TContractState, token_id: u256); + fn unlike_nft(ref self: TContractState, token_id: u256); +} + diff --git a/onchain/cairo/src/nfts/mod.cairo b/onchain/cairo/src/nfts/mod.cairo new file mode 100644 index 00000000..8a7ce18d --- /dev/null +++ b/onchain/cairo/src/nfts/mod.cairo @@ -0,0 +1,11 @@ +pub mod nfts { + // pub mod canvas_nft; + pub mod component; + pub mod interfaces; +// use interfaces::{ +// NFTMintParams, NFTMetadata, IArtPeaceNFTMinter, ICanvasNFTStoreDispatcher, +// ICanvasNFTStoreDispatcherTrait, IArtPeaceNFTMinterDispatcher, +// IArtPeaceNFTMinterDispatcherTrait, ICanvasNFTAdditional, ICanvasNFTLikeAndUnlike, +// ICanvasNFTAdditionalDispatcher, ICanvasNFTAdditionalDispatcherTrait +// }; +} diff --git a/onchain/cairo/src/pixel.cairo b/onchain/cairo/src/pixel.cairo new file mode 100644 index 00000000..0ad4285c --- /dev/null +++ b/onchain/cairo/src/pixel.cairo @@ -0,0 +1 @@ +pub mod pixel; diff --git a/onchain/cairo/src/pixel/art_peace.cairo b/onchain/cairo/src/pixel/art_peace.cairo new file mode 100644 index 00000000..e03097bc --- /dev/null +++ b/onchain/cairo/src/pixel/art_peace.cairo @@ -0,0 +1,1248 @@ +#[starknet::contract] +pub mod ArtPeace { + use afk::interfaces::nfts::{ + IArtPeaceNFTMinter, NFTMetadata, NFTMintParams, ICanvasNFTAdditionalDispatcher, + ICanvasNFTAdditionalDispatcherTrait + }; + use afk::interfaces::pixel::{ + IArtPeace, Pixel, Faction, ChainFaction, MemberMetadata, ColorAdded, NewDay, CanvasScaled, + PixelPlaced, BasicPixelPlaced, FactionPixelsPlaced, ChainFactionPixelsPlaced, + ExtraPixelsPlaced, DailyQuestClaimed, MainQuestClaimed, VoteColor, VotableColorAdded, + FactionCreated, FactionLeaderChanged, ChainFactionCreated, FactionJoined, FactionLeft, + ChainFactionJoined, FactionTemplateAdded, FactionTemplateRemoved, ChainFactionTemplateAdded, + ChainFactionTemplateRemoved, InitParams, + }; + use afk::interfaces::pixel_template::{ + ITemplateVerifier, ITemplateStore, FactionTemplateMetadata, TemplateMetadata + }; + use afk::interfaces::quests::{IQuestDispatcher, IQuestDispatcherTrait}; + use afk::templates::template::TemplateStoreComponent; + use core::dict::Felt252DictTrait; + use core::hash::{HashStateTrait, HashStateExTrait}; + use core::poseidon::PoseidonTrait; + use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait}; + use starknet::ContractAddress; + + component!(path: TemplateStoreComponent, storage: templates, event: TemplateEvent); + + #[abi(embed_v0)] + impl TemplateStoreComponentImpl = + TemplateStoreComponent::TemplateStoreImpl; + + #[storage] + struct Storage { + host: ContractAddress, + // TODO: Add back canvas: LegacyMap::, + canvas_width: u128, + canvas_height: u128, + total_pixels: u128, + // Map: user's address -> last time they placed a pixel + last_placed_time: LegacyMap::, + time_between_pixels: u64, + // Map: user's address -> amount of extra pixels they have + extra_pixels: LegacyMap::, + time_between_member_pixels: u64, + factions_count: u32, + // Map: faction id -> faction data + factions: LegacyMap::, + // Map: members address -> faction id ( 0 => no faction ) + users_faction: LegacyMap::, + // Map: members address -> membership metadata + users_faction_meta: LegacyMap::, + chain_factions_count: u32, + // Map: chain faction id -> chain faction data + chain_factions: LegacyMap::, + // Map: chain members address -> faction id ( 0 => no faction ) + users_chain_faction: LegacyMap::, + // Map: chain members address -> membership metadata + users_chain_faction_meta: LegacyMap::, + // TODO: Extra factions ( assigned at start with larger allocations ) + color_count: u8, + // Map: color index -> color value in RGBA + color_palette: LegacyMap::, + // Map: (day index) -> number of votable colors + votable_colors_count: LegacyMap::, + // Map: (votable color index, day index) -> color value in RGBA + votable_colors: LegacyMap::<(u8, u32), u32>, + // Map: (votable color index, day index) -> amount of votes + color_votes: LegacyMap::<(u8, u32), u32>, + // Map: (user's address, day_index) -> color index + user_votes: LegacyMap::<(ContractAddress, u32), u8>, + daily_new_colors_count: u32, + creation_time: u64, + end_time: u64, + day_index: u32, + start_day_time: u64, + daily_quests_count: u32, + // Map: (day_index, quest_id) -> quest contract address + daily_quests: LegacyMap::<(u32, u32), ContractAddress>, + main_quests_count: u32, + // Map: quest index -> quest contract address + main_quests: LegacyMap::, + nft_contract: ContractAddress, + // Map: (day_index, user's address, color index) -> amount of pixels placed + user_pixels_placed: LegacyMap::<(u32, ContractAddress, u8), u32>, + devmode: bool, + faction_templates_count: u32, + // Map: template id -> template metadata + faction_templates: LegacyMap::, + chain_faction_templates_count: u32, + // Map: template id -> template metadata + chain_faction_templates: LegacyMap::, + #[substorage(v0)] + templates: TemplateStoreComponent::Storage, + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + NewDay: NewDay, + CanvasScaled: CanvasScaled, + ColorAdded: ColorAdded, + PixelPlaced: PixelPlaced, + BasicPixelPlaced: BasicPixelPlaced, + FactionPixelsPlaced: FactionPixelsPlaced, + ChainFactionPixelsPlaced: ChainFactionPixelsPlaced, + ExtraPixelsPlaced: ExtraPixelsPlaced, + DailyQuestClaimed: DailyQuestClaimed, + MainQuestClaimed: MainQuestClaimed, + VoteColor: VoteColor, + FactionCreated: FactionCreated, + FactionLeaderChanged: FactionLeaderChanged, + ChainFactionCreated: ChainFactionCreated, + FactionJoined: FactionJoined, + FactionLeft: FactionLeft, + ChainFactionJoined: ChainFactionJoined, + VotableColorAdded: VotableColorAdded, + FactionTemplateAdded: FactionTemplateAdded, + FactionTemplateRemoved: FactionTemplateRemoved, + ChainFactionTemplateAdded: ChainFactionTemplateAdded, + ChainFactionTemplateRemoved: ChainFactionTemplateRemoved, + // TODO: Integrate template event + #[flat] + TemplateEvent: TemplateStoreComponent::Event, + } + + const DAY_IN_SECONDS: u64 = consteval_int!(60 * 60 * 24); + + #[constructor] + fn constructor(ref self: ContractState, init_params: InitParams) { + self.host.write(init_params.host); + + self.canvas_width.write(init_params.canvas_width); + self.canvas_height.write(init_params.canvas_height); + self.total_pixels.write(init_params.canvas_width * init_params.canvas_height); + + self.time_between_pixels.write(init_params.time_between_pixels); + self.time_between_member_pixels.write(init_params.time_between_pixels); + + let color_count: u8 = init_params.color_palette.len().try_into().unwrap(); + self.color_count.write(color_count); + let mut i: u8 = 0; + while i < color_count { + self.color_palette.write(i, *init_params.color_palette.at(i.into())); + // TODO fix events + self.emit(ColorAdded { color_key: i, color: *init_params.color_palette.at(i.into()) }); + i += 1; + }; + + let votable_colors_count: u8 = init_params.votable_colors.len().try_into().unwrap(); + self.votable_colors_count.write(0, votable_colors_count); + let mut i: u8 = 0; + while i < votable_colors_count { + let new_color = *init_params.votable_colors.at(i.into()); + self.votable_colors.write((i + 1, 0), new_color); + self.emit(VotableColorAdded { day: 0, color_key: i + 1, color: new_color }); + i += 1; + }; + self.daily_new_colors_count.write(init_params.daily_new_colors_count); + + self.creation_time.write(starknet::get_block_timestamp()); + let mut start_time = init_params.start_time; + if start_time == 0 { + start_time = starknet::get_block_timestamp(); + } + self.end_time.write(init_params.end_time); + self.day_index.write(0); + self.emit(NewDay { day_index: 0, start_time: start_time }); + + if init_params.devmode { + let test_address = starknet::contract_address_const::< + 0x328ced46664355fc4b885ae7011af202313056a7e3d44827fb24c9d3206aaa0 + >(); + self.extra_pixels.write(test_address, 1000); + } + self.devmode.write(init_params.devmode); + + self.daily_quests_count.write(init_params.daily_quests_count); + } + + #[abi(embed_v0)] + impl ArtPeaceImpl of IArtPeace { + // fn get_pixel(self: @ContractState, pos: u128) -> Pixel { + // self.canvas.read(pos) + // } + + // fn get_pixel_color(self: @ContractState, pos: u128) -> u8 { + // self.canvas.read(pos).color + // } + + // fn get_pixel_owner(self: @ContractState, pos: u128) -> ContractAddress { + // self.canvas.read(pos).owner + // } + + // fn get_pixel_xy(self: @ContractState, x: u128, y: u128) -> Pixel { + // let pos = x + y * self.canvas_width.read(); + + // self.canvas.read(pos) + // } + + fn get_width(self: @ContractState) -> u128 { + self.canvas_width.read() + } + + fn get_height(self: @ContractState) -> u128 { + self.canvas_height.read() + } + + fn get_total_pixels(self: @ContractState) -> u128 { + self.total_pixels.read() + } + + fn check_game_running(self: @ContractState) { + let block_timestamp = starknet::get_block_timestamp(); + assert(block_timestamp <= self.end_time.read(), 'ArtPeace game has ended'); + } + + fn check_valid_pixel(self: @ContractState, pos: u128, color: u8) { + assert(pos < self.total_pixels.read(), 'Position out of bounds'); + assert(color < self.color_count.read(), 'Color out of bounds'); + } + + fn check_timing(self: @ContractState, now: u64) { + let block_timestamp = starknet::get_block_timestamp(); + // TODO: To config? + let leanience_margin = 20; // 20 seconds + let expected_block_time = 6 * 60; // 6 minutes + assert(now >= block_timestamp - leanience_margin, 'Timestamp too far behind'); + assert(now <= block_timestamp + 2 * expected_block_time, 'Timestamp too far ahead'); + } + + fn place_pixel(ref self: ContractState, pos: u128, color: u8, now: u64) { + self.check_game_running(); + self.check_timing(now); + let caller = starknet::get_caller_address(); + assert( + now - self.last_placed_time.read(caller) >= self.time_between_pixels.read(), + 'Pixel not available' + ); + + place_basic_pixel_inner(ref self, pos, color, now); + } + + fn place_pixel_xy(ref self: ContractState, x: u128, y: u128, color: u8, now: u64) { + let pos = x + y * self.canvas_width.read(); + self.place_pixel(pos, color, now); + } + + fn place_pixel_blocktime(ref self: ContractState, pos: u128, color: u8) { + let block_timestamp = starknet::get_block_timestamp(); + self.place_pixel(pos, color, block_timestamp); + } + + fn place_extra_pixels( + ref self: ContractState, positions: Span, colors: Span, now: u64 + ) { + self.check_game_running(); + self.check_timing(now); + let pixel_count = positions.len(); + assert(pixel_count == colors.len(), 'Positions & Colors must match'); + + // Order to use pixels : user base pixel -> member pixels -> extra pixels + let caller = starknet::get_caller_address(); + let mut pixels_placed = 0; + + // Use base pixel if available + if now - self.last_placed_time.read(caller) >= self.time_between_pixels.read() { + let pos = *positions.at(pixels_placed); + let color = *colors.at(pixels_placed); + place_basic_pixel_inner(ref self, pos, color, now); + pixels_placed += 1; + if pixels_placed == pixel_count { + return; + } + } + + pixels_placed = + place_chain_faction_pixels_inner(ref self, positions, colors, pixels_placed, now); + if pixels_placed == pixel_count { + return; + } + + pixels_placed = + place_user_faction_pixels_inner(ref self, positions, colors, pixels_placed, now); + if pixels_placed == pixel_count { + return; + } + + // TODO: place_extra_pixels_inner + // Use extra pixels + let extra_pixels = self.extra_pixels.read(caller); + let prior_pixels = pixels_placed; + assert(extra_pixels >= pixel_count - prior_pixels, 'Not enough extra pixels'); + while pixels_placed < pixel_count { + let pos = *positions.at(pixels_placed); + let color = *colors.at(pixels_placed); + place_pixel_inner(ref self, pos, color); + pixels_placed += 1; + }; + let extra_pixels_placed = pixel_count - prior_pixels; + self.extra_pixels.write(caller, extra_pixels - extra_pixels_placed); + // TODO fix emit event build + self.emit(ExtraPixelsPlaced { placed_by: caller, extra_pixels: extra_pixels_placed }); + } + + // TODO: Place extra pixels cheaper func: pass pixels to use instead of checking all + + fn get_last_placed_time(self: @ContractState) -> u64 { + self.last_placed_time.read(starknet::get_caller_address()) + } + + fn get_user_last_placed_time(self: @ContractState, user: ContractAddress) -> u64 { + self.last_placed_time.read(user) + } + + fn get_time_between_pixels(self: @ContractState) -> u64 { + self.time_between_pixels.read() + } + + fn get_extra_pixels_count(self: @ContractState) -> u32 { + self.extra_pixels.read(starknet::get_caller_address()) + } + + fn get_user_extra_pixels_count(self: @ContractState, user: ContractAddress) -> u32 { + self.extra_pixels.read(user) + } + + fn get_factions_count(self: @ContractState) -> u32 { + self.factions_count.read() + } + + fn get_faction(self: @ContractState, faction_id: u32) -> Faction { + self.factions.read(faction_id) + } + + fn get_faction_leader(self: @ContractState, faction_id: u32) -> ContractAddress { + self.factions.read(faction_id).leader + } + + // TODO: Tests + fn init_faction( + ref self: ContractState, + name: felt252, + leader: ContractAddress, + joinable: bool, + allocation: u32 + ) { + // TODO: Init with members? + assert( + starknet::get_caller_address() == self.host.read(), 'Factions are set by the host' + ); + self.check_game_running(); + let faction_id = self.factions_count.read() + 1; + let faction = Faction { name, leader, joinable, allocation }; + self.factions.write(faction_id, faction); + self.factions_count.write(faction_id); + // TODO fix emit event + self.emit(FactionCreated { faction_id, name, leader, joinable, allocation }); + } + + fn change_faction_leader( + ref self: ContractState, faction_id: u32, new_leader: ContractAddress + ) { + self.check_game_running(); + assert(faction_id != 0, 'Faction 0 is not changeable'); + assert(faction_id <= self.factions_count.read(), 'Faction does not exist'); + assert( + starknet::get_caller_address() == self.host.read() + || starknet::get_caller_address() == self.factions.read(faction_id).leader, + 'Host or leader changes leader' + ); + let mut faction = self.factions.read(faction_id); + faction.leader = new_leader; + self.factions.write(faction_id, faction); + self.emit(FactionLeaderChanged { faction_id, new_leader }); + } + + fn init_chain_faction(ref self: ContractState, name: felt252) { + assert( + starknet::get_caller_address() == self.host.read(), 'Factions are set by the host' + ); + self.check_game_running(); + let faction_id = self.chain_factions_count.read() + 1; + let chain_faction = ChainFaction { name }; + self.chain_factions.write(faction_id, chain_faction); + self.chain_factions_count.write(faction_id); + self.emit(ChainFactionCreated { faction_id, name }); + } + + fn join_faction(ref self: ContractState, faction_id: u32) { + self.check_game_running(); + assert(faction_id != 0, 'Faction 0 is not joinable'); + assert(faction_id <= self.factions_count.read(), 'Faction does not exist'); + assert( + self.users_faction.read(starknet::get_caller_address()) == 0, + 'User already in a faction' + ); + let caller = starknet::get_caller_address(); + let faction = self.factions.read(faction_id); + assert(faction.joinable, 'Faction is not joinable'); + self.users_faction.write(caller, faction_id); + self.emit(FactionJoined { faction_id, user: caller }); + } + + // TODO + // fn leave_faction(ref self: ContractState) { + // self.check_game_running(); + // let caller = starknet::get_caller_address(); + // let faction_id = self.users_faction.read(caller); + // self.users_faction.write(caller, 0); + // self.emit(FactionLeft { faction_id, user: caller }); + // } + + fn join_chain_faction(ref self: ContractState, faction_id: u32) { + self.check_game_running(); + assert(faction_id != 0, 'Faction 0 is not joinable'); + assert(faction_id <= self.chain_factions_count.read(), 'Faction does not exist'); + assert( + self.users_chain_faction.read(starknet::get_caller_address()) == 0, + 'User already in a chain faction' + ); + let caller = starknet::get_caller_address(); + self.users_chain_faction.write(caller, faction_id); + self.emit(ChainFactionJoined { faction_id, user: caller }); + } + + fn get_user_faction(self: @ContractState, user: ContractAddress) -> u32 { + self.users_faction.read(user) + } + + fn get_user_chain_faction(self: @ContractState, user: ContractAddress) -> u32 { + self.users_chain_faction.read(user) + } + + fn get_user_faction_members_pixels( + self: @ContractState, user: ContractAddress, now: u64 + ) -> u32 { + let faction_id = self.users_faction.read(user); + if faction_id == 0 { + // 0 => no faction + return 0; + } + let member_metadata = self.users_faction_meta.read(user); + if member_metadata.member_pixels > 0 { + // TODO: If member_pixels > 0 && < allocation && enough time has passed, return allocation instead of member_pixels + return member_metadata.member_pixels; + } else { + let time_since_last_pixel = now - member_metadata.member_placed_time; + // TODO: Setup time_between_member_pixels + if time_since_last_pixel < self.time_between_member_pixels.read() { + return 0; + } else { + return self.factions.read(faction_id).allocation; + } + } + } + + fn get_chain_faction_members_pixels( + self: @ContractState, user: ContractAddress, now: u64 + ) -> u32 { + let faction_id = self.users_chain_faction.read(user); + if faction_id == 0 { + // 0 => no faction + return 0; + } + let member_metadata = self.users_chain_faction_meta.read(user); + if member_metadata.member_pixels > 0 { + return member_metadata.member_pixels; + } else { + let time_since_last_pixel = now - member_metadata.member_placed_time; + if time_since_last_pixel < self.time_between_member_pixels.read() { + return 0; + } else { + return 2; // Chain faction allocation + } + } + } + + fn get_color_count(self: @ContractState) -> u8 { + self.color_count.read() + } + + fn get_colors(self: @ContractState) -> Array { + let color_count = self.color_count.read(); + let mut colors = array![]; + let mut i = 0; + while i < color_count { + colors.append(self.color_palette.read(i)); + i += 1; + }; + + colors + } + + fn vote_color(ref self: ContractState, color: u8) { + self.check_game_running(); + let day = self.day_index.read(); + assert(color != 0, 'Color 0 indicates no vote'); + assert(color <= self.votable_colors_count.read(day), 'Color out of bounds'); + let caller = starknet::get_caller_address(); + let users_vote = self.user_votes.read((caller, day)); + if users_vote != color { + if users_vote != 0 { + let old_vote = self.color_votes.read((users_vote, day)); + self.color_votes.write((users_vote, day), old_vote - 1); + } + let new_vote = self.color_votes.read((color, day)); + self.color_votes.write((color, day), new_vote + 1); + self.user_votes.write((caller, day), color); + self.emit(VoteColor { voted_by: caller, day, color }); + } + } + + fn get_color_votes(self: @ContractState, color: u8) -> u32 { + let day = self.day_index.read(); + self.color_votes.read((color, day)) + } + + fn get_user_vote(self: @ContractState, user: ContractAddress, day: u32) -> u8 { + self.user_votes.read((user, day)) + } + + fn get_votable_colors(self: @ContractState) -> Array { + let day = self.day_index.read(); + let votable_colors_count = self.votable_colors_count.read(day); + let mut votable_colors = array![]; + let mut i = 1; + while i <= votable_colors_count { + votable_colors.append(self.votable_colors.read((i, day))); + i += 1; + }; + + votable_colors + } + + fn get_creation_time(self: @ContractState) -> u64 { + self.creation_time.read() + } + + fn get_end_time(self: @ContractState) -> u64 { + self.end_time.read() + } + + fn get_day(self: @ContractState) -> u32 { + self.day_index.read() + } + + // TODO: Integrate call into backend + fn increase_day_index(ref self: ContractState) { + self.check_game_running(); + let block_timestamp = starknet::get_block_timestamp(); + let start_day_time = self.start_day_time.read(); + + if !self.devmode.read() { + assert(block_timestamp >= start_day_time + DAY_IN_SECONDS, 'day has not passed'); + } + finalize_color_votes(ref self); + + self.day_index.write(self.day_index.read() + 1); + self.start_day_time.write(block_timestamp); + self.emit(NewDay { day_index: self.day_index.read(), start_time: block_timestamp }); + } + + fn get_daily_quests_count(self: @ContractState) -> u32 { + self.daily_quests_count.read() + } + + fn get_daily_quest(self: @ContractState, day_index: u32, quest_id: u32) -> ContractAddress { + self.daily_quests.read((day_index, quest_id)) + } + + fn get_days_quests(self: @ContractState, day_index: u32) -> Span { + let mut i = 0; + let mut quests = array![]; + let quest_count = self.get_daily_quests_count(); + while i < quest_count { + quests.append(self.daily_quests.read((day_index, i))); + i += 1; + }; + + quests.span() + } + + fn get_today_quests(self: @ContractState) -> Span { + let day = self.day_index.read(); + let mut quests = array![]; + let mut i = 0; + let quest_count = self.get_daily_quests_count(); + while i < quest_count { + quests.append(self.daily_quests.read((day, i))); + i += 1; + }; + + quests.span() + } + + fn get_main_quest_count(self: @ContractState) -> u32 { + self.main_quests_count.read() + } + + fn get_main_quest(self: @ContractState, quest_id: u32) -> ContractAddress { + self.main_quests.read(quest_id) + } + + fn get_main_quests(self: @ContractState) -> Span { + let mut i = 0; + let mut quests = array![]; + let quest_count = self.main_quests_count.read(); + while i < quest_count { + quests.append(self.main_quests.read(i)); + i += 1; + }; + + quests.span() + } + + fn add_daily_quests( + ref self: ContractState, day_index: u32, quests: Span + ) { + self.check_game_running(); + assert( + starknet::get_caller_address() == self.host.read(), 'Quests are set by the host' + ); + assert(quests.len() <= self.get_daily_quests_count(), 'Invalid daily quests count'); + let zero_address = starknet::contract_address_const::<0>(); + assert( + self.daily_quests.read((day_index, 0)) == zero_address, 'Daily quests already set' + ); + let mut i = 0; + while i < quests + .len() { + self.daily_quests.write((day_index, i), *quests.at(i)); + i += 1; + }; + } + + fn add_main_quests(ref self: ContractState, quests: Span) { + self.check_game_running(); + assert( + starknet::get_caller_address() == self.host.read(), 'Quests are set by the host' + ); + let mut i = self.main_quests_count.read(); + let end = i + quests.len(); + while i < end { + // TODO: This should be i - self.main_quests_count.read() + self.main_quests.write(i, *quests.at(i)); + i += 1; + }; + self.main_quests_count.write(end); + } + + fn claim_today_quest(ref self: ContractState, quest_id: u32, calldata: Span) { + self.check_game_running(); + let day_index = self.day_index.read(); + let quest = self.daily_quests.read((day_index, quest_id)); + assert(quest != starknet::contract_address_const::<0>(), 'This quest is unavailable'); + let user = starknet::get_caller_address(); + let reward = IQuestDispatcher { contract_address: quest }.claim(user, calldata); + if reward > 0 { + self + .extra_pixels + .write( + starknet::get_caller_address(), + self.extra_pixels.read(starknet::get_caller_address()) + reward + ); + } + self.emit(DailyQuestClaimed { day_index, quest_id, user, reward, calldata }); + } + + fn claim_main_quest(ref self: ContractState, quest_id: u32, calldata: Span) { + self.check_game_running(); + let quest = self.main_quests.read(quest_id); + let user = starknet::get_caller_address(); + let reward = IQuestDispatcher { contract_address: quest }.claim(user, calldata); + if reward > 0 { + self + .extra_pixels + .write( + starknet::get_caller_address(), + self.extra_pixels.read(starknet::get_caller_address()) + reward + ); + } + self.emit(MainQuestClaimed { quest_id, user, reward, calldata }); + } + + fn get_nft_contract(self: @ContractState) -> ContractAddress { + self.nft_contract.read() + } + + fn add_faction_template( + ref self: ContractState, template_metadata: FactionTemplateMetadata + ) { + self.check_game_running(); + assert( + starknet::get_caller_address() == self.host.read() + || starknet::get_caller_address() == self + .factions + .read(template_metadata.faction_id) + .leader, + 'Host or leader sets templates' + ); + assert( + template_metadata.position < self.canvas_width.read() * self.canvas_height.read(), + 'Template position out of bounds' + ); + let MAX_TEMPLATE_SIZE: u128 = 64; + let MIN_TEMPLATE_SIZE: u128 = 5; + assert( + template_metadata.width >= MIN_TEMPLATE_SIZE + && template_metadata.width <= MAX_TEMPLATE_SIZE, + 'Template width out of bounds' + ); + assert( + template_metadata.height >= MIN_TEMPLATE_SIZE + && template_metadata.height <= MAX_TEMPLATE_SIZE, + 'Template height out of bounds' + ); + assert( + template_metadata.faction_id <= self.factions_count.read(), 'Faction does not exist' + ); + let template_id = self.faction_templates_count.read(); + self.faction_templates.write(template_id, template_metadata); + self.faction_templates_count.write(template_id + 1); + self.emit(FactionTemplateAdded { template_id, template_metadata }); + } + + fn remove_faction_template(ref self: ContractState, template_id: u32) { + self.check_game_running(); + let template_metadata = self.faction_templates.read(template_id); + assert( + starknet::get_caller_address() == self.host.read() + || starknet::get_caller_address() == self + .factions + .read(template_metadata.faction_id) + .leader, + 'Host or leader sets templates' + ); + // Don't need to actually remove the template, just mark it as removed + self.emit(FactionTemplateRemoved { template_id }); + } + + fn add_chain_faction_template( + ref self: ContractState, template_metadata: FactionTemplateMetadata + ) { + self.check_game_running(); + assert(starknet::get_caller_address() == self.host.read(), 'Host sets chain templates'); + assert( + template_metadata.position < self.canvas_width.read() * self.canvas_height.read(), + 'Template position out of bounds' + ); + let MAX_TEMPLATE_SIZE: u128 = 64; + let MIN_TEMPLATE_SIZE: u128 = 5; + assert( + template_metadata.width >= MIN_TEMPLATE_SIZE + && template_metadata.width <= MAX_TEMPLATE_SIZE, + 'Template width out of bounds' + ); + assert( + template_metadata.height >= MIN_TEMPLATE_SIZE + && template_metadata.height <= MAX_TEMPLATE_SIZE, + 'Template height out of bounds' + ); + assert( + template_metadata.faction_id <= self.chain_factions_count.read(), + 'Faction does not exist' + ); + let template_id = self.chain_faction_templates_count.read(); + self.chain_faction_templates.write(template_id, template_metadata); + self.chain_faction_templates_count.write(template_id + 1); + self.emit(ChainFactionTemplateAdded { template_id, template_metadata }); + } + + fn remove_chain_faction_template(ref self: ContractState, template_id: u32) { + self.check_game_running(); + assert(starknet::get_caller_address() == self.host.read(), 'Host sets chain templates'); + // Don't need to actually remove the template, just mark it as removed + self.emit(ChainFactionTemplateRemoved { template_id }); + } + + fn get_user_pixels_placed(self: @ContractState, user: ContractAddress) -> u32 { + let mut i = 0; + let mut total = 0; + let last_day = self.day_index.read() + 1; + let color_count = self.color_count.read(); + while i < last_day { + let mut j = 0; + while j < color_count { + total += self.user_pixels_placed.read((i, user, j)); + j += 1; + }; + i += 1; + }; + + total + } + + fn get_user_pixels_placed_day( + self: @ContractState, user: ContractAddress, day: u32 + ) -> u32 { + let mut total = 0; + let color_count = self.color_count.read(); + let mut i = 0; + while i < color_count { + total += self.user_pixels_placed.read((day, user, i)); + i += 1; + }; + + total + } + + fn get_user_pixels_placed_color( + self: @ContractState, user: ContractAddress, color: u8 + ) -> u32 { + let mut total = 0; + let last_day = self.day_index.read() + 1; + let mut i = 0; + while i < last_day { + total += self.user_pixels_placed.read((i, user, color)); + i += 1; + }; + total + } + + fn get_user_pixels_placed_day_color( + self: @ContractState, user: ContractAddress, day: u32, color: u8 + ) -> u32 { + self.user_pixels_placed.read((day, user, color)) + } + } + + #[abi(embed_v0)] + impl ArtPeaceNFTMinter of IArtPeaceNFTMinter { + fn add_nft_contract(ref self: ContractState, nft_contract: ContractAddress) { + self.check_game_running(); + assert( + starknet::get_caller_address() == self.host.read(), + 'NFT contract is set by the host' + ); + let zero_address = starknet::contract_address_const::<0>(); + assert(self.nft_contract.read() == zero_address, 'NFT contract already set'); + self.nft_contract.write(nft_contract); + ICanvasNFTAdditionalDispatcher { contract_address: nft_contract } + .set_canvas_contract(starknet::get_contract_address()); + } + + fn mint_nft(ref self: ContractState, mint_params: NFTMintParams) { + self.check_game_running(); + // TODO: To config? + let MIN_NFT_SIZE: u128 = 1; + let MAX_NFT_SIZE: u128 = 64; + assert( + mint_params.width >= MIN_NFT_SIZE && mint_params.width <= MAX_NFT_SIZE, + 'NFT width out of bounds' + ); + assert( + mint_params.height >= MIN_NFT_SIZE && mint_params.height <= MAX_NFT_SIZE, + 'NFT height out of bounds' + ); + assert( + mint_params.position < self.canvas_width.read() * self.canvas_height.read(), + 'NFT position out of bounds' + ); + let metadata = NFTMetadata { + position: mint_params.position, + width: mint_params.width, + height: mint_params.height, + name: mint_params.name, + image_hash: 0, // TODO + block_number: starknet::get_block_number(), + day_index: self.day_index.read(), + minter: starknet::get_caller_address(), + }; + ICanvasNFTAdditionalDispatcher { contract_address: self.nft_contract.read(), } + .mint(metadata, starknet::get_caller_address()); + } + + fn set_nft_base_uri(ref self: ContractState, base_uri: ByteArray) { + // Use incase of changes in the backed routing + assert( + starknet::get_caller_address() == self.host.read(), + 'NFT base URI is set by the host' + ); + ICanvasNFTAdditionalDispatcher { contract_address: self.nft_contract.read() } + .set_base_uri(base_uri); + } + } + + + #[abi(embed_v0)] + impl ArtPeaceTemplateVerifier of ITemplateVerifier { + fn compute_template_hash(self: @ContractState, template: Span) -> felt252 { + let template_len = template.len(); + if template_len == 0 { + return 0; + } + + let mut hasher = PoseidonTrait::new(); + let mut i = 0; + while i < template_len { + hasher = hasher.update_with(*template.at(i)); + i += 1; + }; + + hasher.finalize() + } + + fn complete_template(ref self: ContractState, template_id: u32, template_image: Span) { + self.check_game_running(); + assert(template_id < self.get_templates_count(), 'Template ID out of bounds'); + assert(!self.is_template_complete(template_id), 'Template already completed'); + let template_metadata: TemplateMetadata = self.get_template(template_id); + assert(template_metadata.reward == 0, 'Template has a reward'); + let template_hash = self.compute_template_hash(template_image); + assert(template_hash == template_metadata.hash, 'Template hash mismatch'); + let template_size = template_metadata.width * template_metadata.height; + assert(template_image.len().into() == template_size, 'Template image size mismatch'); + + let non_zero_width: core::zeroable::NonZero:: = template_metadata + .width + .try_into() + .unwrap(); + let (template_pos_y, template_pos_x) = DivRem::div_rem( + template_metadata.position, non_zero_width + ); + let canvas_width = self.canvas_width.read(); + let (mut x, mut y) = (0, 0); + let mut matches = 0; + while y < template_metadata + .height { + x = 0; + while x < template_metadata + .width { + let _pos = template_pos_x + x + (template_pos_y + y) * canvas_width; + let _color = *template_image + .at((x + y * template_metadata.width).try_into().unwrap()); + // TODO: Check if the color is transparent + // TODO: Add back + // if color == self.canvas.read(pos).color { + if false { + matches += 1; + } + x += 1; + }; + y += 1; + }; + + // TODO: Allow some threshold? + if matches == template_metadata.width * template_metadata.height { + self.templates.completed_templates.write(template_id, true); + // self.emit(Event::TemplateEvent::TemplateCompleted { template_id }); + } + } + + // TODO: Change to have users claim rewards + fn complete_template_with_rewards( + ref self: ContractState, template_id: u32, template_image: Span + ) { + self.check_game_running(); + assert(template_id < self.get_templates_count(), 'Template ID out of bounds'); + assert(!self.is_template_complete(template_id), 'Template already completed'); + let template_metadata: TemplateMetadata = self.get_template(template_id); + assert(template_metadata.reward > 0, 'Template has no reward'); + let template_hash = self.compute_template_hash(template_image); + assert(template_hash == template_metadata.hash, 'Template hash mismatch'); + let template_size = template_metadata.width * template_metadata.height; + assert(template_image.len().into() == template_size, 'Template image size mismatch'); + + let contract = starknet::get_contract_address(); + let mut pixel_contributors: Array = ArrayTrait::new(); + let mut total_pixels_by_user: Felt252Dict = Default::default(); + let mut pixel_contributors_indexes: Felt252Dict = Default::default(); + let non_zero_width: core::zeroable::NonZero:: = template_metadata + .width + .try_into() + .unwrap(); + let (template_pos_y, template_pos_x) = DivRem::div_rem( + template_metadata.position, non_zero_width + ); + let canvas_width = self.canvas_width.read(); + let (mut x, mut y) = (0, 0); + let mut matches = 0; + while y < template_metadata + .height { + x = 0; + while x < template_metadata + .width { + let _pos = template_pos_x + x + (template_pos_y + y) * canvas_width; + let _color = *template_image + .at((x + y * template_metadata.width).try_into().unwrap()); + // TODO: Check if the color is transparent + // TODO: Add back + // if color == self.canvas.read(pos).color { + if false { + matches += 1; + + let mut pixel_owner = starknet::contract_address_const::< + 0 + >(); // TODO: self.canvas.read(pos).owner; + let user_index = pixel_contributors_indexes.get(pixel_owner.into()); + + if user_index == 0 { + let new_index = pixel_contributors.len() + 1; + + pixel_contributors.append(pixel_owner); + pixel_contributors_indexes + .insert(pixel_owner.into(), new_index); + total_pixels_by_user.insert(new_index.into(), 1); + } else { + let count = total_pixels_by_user.get(user_index.into()); + total_pixels_by_user.insert(user_index.into(), count + 1); + } + } + x += 1; + }; + y += 1; + }; + + // TODO: Allow some threshold? + if matches == template_metadata.width * template_metadata.height { + self.templates.completed_templates.write(template_id, true); + // Distribute rewards + let mut i = 0; + while i < pixel_contributors + .len() { + let reward_token = template_metadata.reward_token; + let reward_amount = template_metadata.reward; + let total_pixels_in_template = template_metadata.width + * template_metadata.height; + + let mut user = *pixel_contributors.at(i).into(); + let user_index = (i + 1); + let user_total_pixels = total_pixels_by_user.get(user_index.into()); + + // TODO: Handle remainder of funds + let user_reward = (reward_amount * user_total_pixels.into()) + / total_pixels_in_template.into(); + + assert( + IERC20Dispatcher { contract_address: reward_token } + .balance_of(contract) >= user_reward, + 'insufficient funds' + ); + let success = IERC20Dispatcher { contract_address: reward_token } + .transfer(user, user_reward); + assert(success, 'ERC20 transfer fail!'); + i += 1; + }; + // self.emit(Event::TemplateEvent::TemplateCompleted { template_id }); + } + } + } + + /// Internals + fn finalize_color_votes(ref self: ContractState) { + let daily_new_colors_count = self.daily_new_colors_count.read(); + let day = self.day_index.read(); + let votable_colors_count = self.votable_colors_count.read(day); + + let mut max_scores: Felt252Dict = Default::default(); + let mut votable_index: u8 = 1; // 0 means no vote + while votable_index <= votable_colors_count { + let vote = self.color_votes.read((votable_index, day)); + if vote <= max_scores.get(daily_new_colors_count.into() - 1) { + votable_index += 1; + continue; + } + // update max scores if needed + let mut max_scores_index: u32 = 0; + while max_scores_index < daily_new_colors_count { + if max_scores.get(max_scores_index.into()) < vote { + // shift scores + let mut i: u32 = daily_new_colors_count - 1; + while i > max_scores_index { + max_scores.insert(i.into(), max_scores.get(i.into() - 1)); + i -= 1; + }; + max_scores.insert(max_scores_index.into(), vote); + break; + } + max_scores_index += 1; + }; + votable_index += 1; + }; + + // find threshold + let mut threshold: u32 = 0; + let mut min_index = daily_new_colors_count; + while threshold == 0 + && min_index > 0 { + min_index -= 1; + threshold = max_scores.get(min_index.into()); + }; + if threshold == 0 { + // No votes + threshold = 1; + } + + // update palette & votable colors + let next_day = day + 1; + let start_day_time = self.start_day_time.read(); + let end_day_time = start_day_time + DAY_IN_SECONDS; + let end_game_time = self.end_time.read(); + let start_new_vote: bool = end_day_time < end_game_time; + let mut color_index = self.color_count.read(); + let mut next_day_votable_index = 1; + votable_index = 1; + while votable_index <= votable_colors_count { + let vote = self.color_votes.read((votable_index, day)); + let color = self.votable_colors.read((votable_index, day)); + if vote >= threshold { + self.color_palette.write(color_index, color); + self.emit(ColorAdded { color_key: color_index, color }); + color_index += 1; + } else if start_new_vote { + self.votable_colors.write((next_day_votable_index, next_day), color); + self + .emit( + VotableColorAdded { + day: next_day, color_key: next_day_votable_index, color + } + ); + next_day_votable_index += 1; + } + votable_index += 1; + }; + self.color_count.write(color_index); + if start_new_vote { + self.votable_colors_count.write(next_day, next_day_votable_index - 1); + } + } + + fn place_pixel_inner(ref self: ContractState, pos: u128, color: u8) { + self.check_valid_pixel(pos, color); + + let caller = starknet::get_caller_address(); + // TODO: let pixel = Pixel { color, owner: caller }; + // TODO: self.canvas.write(pos, pixel); + let day = self.day_index.read(); + self + .user_pixels_placed + .write((day, caller, color), self.user_pixels_placed.read((day, caller, color)) + 1); + // TODO: Optimize? + self.emit(PixelPlaced { placed_by: caller, pos, day, color }); + } + + // TODO: Make the function internal + fn place_basic_pixel_inner(ref self: ContractState, pos: u128, color: u8, now: u64) { + place_pixel_inner(ref self, pos, color); + let caller = starknet::get_caller_address(); + self.last_placed_time.write(caller, now); + self.emit(BasicPixelPlaced { placed_by: caller, timestamp: now }); + } + + fn place_user_faction_pixels_inner( + ref self: ContractState, positions: Span, colors: Span, mut offset: u32, now: u64 + ) -> u32 { + let faction_pixels = self + .get_user_faction_members_pixels(starknet::get_caller_address(), now); + if faction_pixels == 0 { + return offset; + } + + let pixel_count = positions.len(); + let mut faction_pixels_left = faction_pixels; + while faction_pixels_left > 0 { + let pos = *positions.at(offset); + let color = *colors.at(offset); + place_pixel_inner(ref self, pos, color); + offset += 1; + faction_pixels_left -= 1; + if offset == pixel_count { + break; + } + }; + let caller = starknet::get_caller_address(); + if faction_pixels_left == 0 { + let new_member_metadata = MemberMetadata { member_placed_time: now, member_pixels: 0 }; + self.users_faction_meta.write(caller, new_member_metadata); + self.emit(FactionPixelsPlaced { user: caller, placed_time: now, member_pixels: 0 }); + } else { + let last_placed_time = self.users_faction_meta.read(caller).member_placed_time; + let new_member_metadata = MemberMetadata { + member_placed_time: last_placed_time, member_pixels: faction_pixels_left + }; + self.users_faction_meta.write(caller, new_member_metadata); + self + .emit( + FactionPixelsPlaced { + user: caller, + placed_time: last_placed_time, + member_pixels: faction_pixels_left + } + ); + } + return offset; + } + + fn place_chain_faction_pixels_inner( + ref self: ContractState, positions: Span, colors: Span, mut offset: u32, now: u64 + ) -> u32 { + let pixel_count = positions.len(); + let caller = starknet::get_caller_address(); + let member_pixels = self.get_chain_faction_members_pixels(caller, now); + let mut member_pixels_left = member_pixels; + while member_pixels_left > 0 { + let pos = *positions.at(offset); + let color = *colors.at(offset); + place_pixel_inner(ref self, pos, color); + offset += 1; + member_pixels_left -= 1; + if offset == pixel_count { + break; + } + }; + let caller = starknet::get_caller_address(); + if member_pixels != 0 { + if member_pixels_left == 0 { + let new_member_metadata = MemberMetadata { + member_placed_time: now, member_pixels: 0 + }; + self.users_chain_faction_meta.write(caller, new_member_metadata); + self + .emit( + ChainFactionPixelsPlaced { + user: caller, placed_time: now, member_pixels: 0 + } + ); + } else { + let last_placed_time = self + .users_chain_faction_meta + .read(caller) + .member_placed_time; + let new_member_metadata = MemberMetadata { + member_placed_time: last_placed_time, member_pixels: member_pixels_left + }; + self.users_chain_faction_meta.write(caller, new_member_metadata); + self + .emit( + ChainFactionPixelsPlaced { + user: caller, + placed_time: last_placed_time, + member_pixels: member_pixels_left + } + ); + } + } + return offset; + } +} diff --git a/onchain/cairo/src/pixel/mocks/erc20_mock.cairo b/onchain/cairo/src/pixel/mocks/erc20_mock.cairo new file mode 100644 index 00000000..cdf3e198 --- /dev/null +++ b/onchain/cairo/src/pixel/mocks/erc20_mock.cairo @@ -0,0 +1,42 @@ +// +// https://github.com/OpenZeppelin/cairo-contracts/blob/main/src/tests/mocks/erc20_mocks.cairo +// + +#[starknet::contract] +pub(crate) mod SnakeERC20Mock { + use openzeppelin::token::erc20::ERC20Component; + use starknet::ContractAddress; + + component!(path: ERC20Component, storage: erc20, event: ERC20Event); + + #[abi(embed_v0)] + impl ERC20Impl = ERC20Component::ERC20Impl; + #[abi(embed_v0)] + impl ERC20MetadataImpl = ERC20Component::ERC20MetadataImpl; + impl InternalImpl = ERC20Component::InternalImpl; + + #[storage] + struct Storage { + #[substorage(v0)] + erc20: ERC20Component::Storage + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + ERC20Event: ERC20Component::Event + } + + #[constructor] + fn constructor( + ref self: ContractState, + name: ByteArray, + symbol: ByteArray, + initial_supply: u256, + recipient: ContractAddress + ) { + self.erc20.initializer(name, symbol); + self.erc20._mint(recipient, initial_supply); + } +} diff --git a/onchain/cairo/src/pixel/mocks/unruggable_token.cairo b/onchain/cairo/src/pixel/mocks/unruggable_token.cairo new file mode 100644 index 00000000..025eed4c --- /dev/null +++ b/onchain/cairo/src/pixel/mocks/unruggable_token.cairo @@ -0,0 +1,52 @@ +// +// https://github.com/OpenZeppelin/cairo-contracts/blob/main/src/tests/mocks/erc20_mocks.cairo +// + +#[starknet::contract] +pub(crate) mod UnruggableMock { + use art_peace::quests::interfaces::IUnruggableMemecoin; + use openzeppelin::token::erc20::ERC20Component; + use starknet::ContractAddress; + + component!(path: ERC20Component, storage: erc20, event: ERC20Event); + + #[abi(embed_v0)] + impl ERC20Impl = ERC20Component::ERC20Impl; + #[abi(embed_v0)] + impl ERC20MetadataImpl = ERC20Component::ERC20MetadataImpl; + impl InternalImpl = ERC20Component::InternalImpl; + + #[storage] + struct Storage { + is_launched: bool, + owner: ContractAddress, + #[substorage(v0)] + erc20: ERC20Component::Storage + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + #[flat] + ERC20Event: ERC20Component::Event + } + + #[constructor] + fn constructor( + ref self: ContractState, name: ByteArray, symbol: ByteArray, owner: ContractAddress + ) { + self.erc20.initializer(name, symbol); + self.owner.write(owner); + self.is_launched.write(true); + } + + #[abi(embed_v0)] + impl UnruggableImpl of IUnruggableMemecoin { + fn owner(self: @ContractState) -> ContractAddress { + self.owner.read() + } + fn is_launched(self: @ContractState) -> bool { + self.is_launched.read() + } + } +} diff --git a/onchain/cairo/src/pixel/mod.cairo b/onchain/cairo/src/pixel/mod.cairo new file mode 100644 index 00000000..2838bf66 --- /dev/null +++ b/onchain/cairo/src/pixel/mod.cairo @@ -0,0 +1,13 @@ +pub mod templates; +pub use art_peace::ArtPeace; + +pub mod pixel { + pub mod art_peace; + // pub mod templates; + pub use art_peace::ArtPeace; + + + pub mod templates { + pub mod component; + } +} diff --git a/onchain/cairo/src/pixel/templates/component.cairo b/onchain/cairo/src/pixel/templates/component.cairo new file mode 100644 index 00000000..ef0adfef --- /dev/null +++ b/onchain/cairo/src/pixel/templates/component.cairo @@ -0,0 +1,100 @@ +#[starknet::component] +pub mod TemplateStoreComponent { + use afk::interfaces::pixel_template::{ITemplateStore, TemplateMetadata}; + use core::num::traits::Zero; + use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait}; + use starknet::{ContractAddress, get_caller_address}; + + #[storage] + struct Storage { + templates_count: u32, + // Map: template_id -> template_metadata + templates: LegacyMap::, + // Map: template_id -> is_completed + completed_templates: LegacyMap::, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + TemplateAdded: TemplateAdded, + TemplateCompleted: TemplateCompleted, + } + + #[derive(Drop, starknet::Event)] + struct TemplateAdded { + #[key] + id: u32, + metadata: TemplateMetadata, + } + + #[derive(Drop, starknet::Event)] + struct TemplateCompleted { + #[key] + id: u32, + // TODO: Users rewarded, ... + } + + #[embeddable_as(TemplateStoreImpl)] + impl TemplateStore< + TContractState, +HasComponent + > of ITemplateStore> { + fn get_templates_count(self: @ComponentState) -> u32 { + self.templates_count.read() + } + + fn get_template( + self: @ComponentState, template_id: u32 + ) -> TemplateMetadata { + self.templates.read(template_id) + } + + fn get_template_hash(self: @ComponentState, template_id: u32) -> felt252 { + let metadata: TemplateMetadata = self.templates.read(template_id); + + metadata.hash + } + + // TODO: Return idx of the template? + fn add_template( + ref self: ComponentState, template_metadata: TemplateMetadata + ) { + let template_id = self.templates_count.read(); + self.templates.write(template_id, template_metadata); + self.templates_count.write(template_id + 1); + + if !template_metadata.reward_token.is_zero() && template_metadata.reward != 0 { + self.deposit(template_metadata.reward_token, template_metadata.reward); + } + + self.emit(TemplateAdded { id: template_id, metadata: template_metadata }); + } + + fn is_template_complete(self: @ComponentState, template_id: u32) -> bool { + self.completed_templates.read(template_id) + } + } + + #[generate_trait] + impl InternalImpl< + TContractState, +HasComponent + > of InternalTrait { + fn deposit( + ref self: ComponentState, + reward_token: ContractAddress, + reward_amount: u256 + ) { + let caller_address = get_caller_address(); + let contract_address = starknet::get_contract_address(); + assert(!get_caller_address().is_zero(), 'Invalid caller'); + + let erc20_dispatcher = IERC20Dispatcher { contract_address: reward_token }; + let allowance = erc20_dispatcher.allowance(caller_address, contract_address); + assert(allowance >= reward_amount, 'Insufficient allowance'); + + let success = erc20_dispatcher + .transfer_from(caller_address, contract_address, reward_amount); + assert(success, 'Transfer failed'); + } + } +} diff --git a/onchain/cairo/src/pixel/templates/mod.cairo b/onchain/cairo/src/pixel/templates/mod.cairo new file mode 100644 index 00000000..ca7fdc63 --- /dev/null +++ b/onchain/cairo/src/pixel/templates/mod.cairo @@ -0,0 +1,6 @@ +// pub mod component; +// pub mod interfaces; +pub mod templates { + pub mod component; + pub mod interfaces; +} diff --git a/onchain/cairo/src/pixel/username_store/interfaces.cairo b/onchain/cairo/src/pixel/username_store/interfaces.cairo new file mode 100644 index 00000000..168a27ab --- /dev/null +++ b/onchain/cairo/src/pixel/username_store/interfaces.cairo @@ -0,0 +1,9 @@ +use starknet::ContractAddress; + +#[starknet::interface] +pub trait IUsernameStore { + fn claim_username(ref self: TContractState, key: felt252); + fn change_username(ref self: TContractState, new_username: felt252); + fn get_username(self: @TContractState, address: ContractAddress) -> felt252; + fn get_username_address(self: @TContractState, key: felt252) -> ContractAddress; +} diff --git a/onchain/cairo/src/pixel/username_store/username_store.cairo b/onchain/cairo/src/pixel/username_store/username_store.cairo new file mode 100644 index 00000000..615f8bfd --- /dev/null +++ b/onchain/cairo/src/pixel/username_store/username_store.cairo @@ -0,0 +1,96 @@ +pub mod UserNameClaimErrors { + pub const USERNAME_CLAIMED: felt252 = 'Username already claimed'; + pub const USER_HAS_USERNAME: felt252 = 'User already has a username'; + pub const USER_DOESNT_HAVE_USERNAME: felt252 = 'User does not have a username'; +} + +#[starknet::contract] +pub mod UsernameStore { + use art_peace::username_store::IUsernameStore; + use starknet::{ContractAddress, contract_address_const, get_caller_address}; + use super::UserNameClaimErrors; + + #[storage] + struct Storage { + usernames: LegacyMap::, + user_to_username: LegacyMap:: + } + + #[event] + #[derive(Drop, starknet::Event)] + enum Event { + UserNameClaimed: UserNameClaimed, + UserNameChanged: UserNameChanged + } + + #[derive(Drop, starknet::Event)] + struct UserNameClaimed { + #[key] + address: ContractAddress, + username: felt252 + } + + #[derive(Drop, starknet::Event)] + struct UserNameChanged { + #[key] + address: ContractAddress, + old_username: felt252, + new_username: felt252 + } + + #[abi(embed_v0)] + impl UsernameStore of IUsernameStore { + fn claim_username(ref self: ContractState, key: felt252) { + let caller_address = get_caller_address(); + + assert( + self.user_to_username.read(caller_address) == 0, + UserNameClaimErrors::USER_HAS_USERNAME + ); + + let username_address = self.usernames.read(key); + assert( + username_address == contract_address_const::<0>(), + UserNameClaimErrors::USERNAME_CLAIMED + ); + + self.usernames.write(key, caller_address); + self.user_to_username.write(caller_address, key); + + self.emit(UserNameClaimed { username: key, address: caller_address }); + } + + fn change_username(ref self: ContractState, new_username: felt252) { + let caller_address = get_caller_address(); + let old_username = self.user_to_username.read(caller_address); + assert(old_username != 0, UserNameClaimErrors::USER_DOESNT_HAVE_USERNAME); + + let new_username_address = self.usernames.read(new_username); + assert( + new_username_address == contract_address_const::<0>(), + UserNameClaimErrors::USERNAME_CLAIMED + ); + + self.usernames.write(old_username, contract_address_const::<0>()); + self.usernames.write(new_username, caller_address); + self.user_to_username.write(caller_address, new_username); + + self + .emit( + UserNameChanged { + old_username: old_username, + new_username: new_username, + address: caller_address + } + ); + } + + fn get_username(self: @ContractState, address: ContractAddress) -> felt252 { + self.user_to_username.read(address) + } + + fn get_username_address(self: @ContractState, key: felt252) -> ContractAddress { + self.usernames.read(key) + } + } +} diff --git a/onchain/cairo/src/quests/authority_quest.cairo b/onchain/cairo/src/quests/authority_quest.cairo new file mode 100644 index 00000000..d3651d46 --- /dev/null +++ b/onchain/cairo/src/quests/authority_quest.cairo @@ -0,0 +1,79 @@ +#[starknet::contract] +pub mod AuthorityQuest { + use afk::interfaces::pixel_template::{ITemplateStoreDispatcher, ITemplateStoreDispatcherTrait}; + use afk::interfaces::quests::{IAuthorityQuest, IQuest}; + + use starknet::{ContractAddress, get_caller_address}; + + #[storage] + struct Storage { + art_peace: ContractAddress, + authority: ContractAddress, + reward: u32, + claimable: LegacyMap, + claimed: LegacyMap, + } + + #[derive(Drop, Serde)] + pub struct AuthorityQuestInitParams { + pub art_peace: ContractAddress, + pub authority: ContractAddress, + pub reward: u32, + } + + #[constructor] + fn constructor(ref self: ContractState, init_params: AuthorityQuestInitParams) { + self.art_peace.write(init_params.art_peace); + self.authority.write(init_params.authority); + self.reward.write(init_params.reward); + } + + #[abi(embed_v0)] + impl AuthorityQuestImpl of IAuthorityQuest { + fn is_claimed(self: @ContractState, user: ContractAddress) -> bool { + self.claimed.read(user) + } + + fn mark_claimable(ref self: ContractState, calldata: Span) { + assert(get_caller_address() == self.authority.read(), 'Only authority address allowed'); + let mut i = 0; + while i < calldata + .len() { + self.claimable.write((*calldata[i]).try_into().unwrap(), true); + i += 1; + } + } + } + + #[abi(embed_v0)] + impl AuthorityQuestTemplateImpl of IQuest { + fn get_reward(self: @ContractState) -> u32 { + self.reward.read() + } + + fn is_claimable( + self: @ContractState, user: ContractAddress, calldata: Span + ) -> bool { + if self.claimed.read(user) { + return false; + } + + if self.claimable.read(user) { + true + } else { + false + } + } + + fn claim(ref self: ContractState, user: ContractAddress, calldata: Span) -> u32 { + assert(get_caller_address() == self.art_peace.read(), 'Only ArtPeace can claim quests'); + + assert(self.is_claimable(user, calldata), 'Quest not claimable'); + + self.claimed.write(user, true); + let reward = self.reward.read(); + + reward + } + } +} diff --git a/onchain/cairo/src/quests/chain_faction_quest.cairo b/onchain/cairo/src/quests/chain_faction_quest.cairo new file mode 100644 index 00000000..8911664f --- /dev/null +++ b/onchain/cairo/src/quests/chain_faction_quest.cairo @@ -0,0 +1,68 @@ +#[starknet::contract] +pub mod ChainFactionQuest { + use afk::interfaces::pixel::{IArtPeaceDispatcher, IArtPeaceDispatcherTrait}; + use afk::interfaces::quests::{IAuthorityQuest, IQuest}; + + + use starknet::{ContractAddress, get_caller_address}; + + #[storage] + struct Storage { + art_peace: ContractAddress, + reward: u32, + claimed: LegacyMap, + } + + #[derive(Drop, Serde)] + pub struct ChainFactionQuestInitParams { + pub art_peace: ContractAddress, + pub reward: u32, + } + + #[constructor] + fn constructor(ref self: ContractState, init_params: ChainFactionQuestInitParams) { + self.art_peace.write(init_params.art_peace); + self.reward.write(init_params.reward); + } + + + #[abi(embed_v0)] + impl ChainFactionQuestImpl of IQuest { + fn get_reward(self: @ContractState) -> u32 { + self.reward.read() + } + + fn is_claimable( + self: @ContractState, user: ContractAddress, calldata: Span + ) -> bool { + if self.claimed.read(user) { + return false; + } + + let art_peace_dispatcher = IArtPeaceDispatcher { + contract_address: self.art_peace.read() + }; + + let user_faction = art_peace_dispatcher.get_user_chain_faction(user); + + if user_faction == 0 { + return false; + } + + return true; + } + + + fn claim(ref self: ContractState, user: ContractAddress, calldata: Span) -> u32 { + assert(get_caller_address() == self.art_peace.read(), 'Only ArtPeace can claim quests'); + + assert(self.is_claimable(user, calldata), 'Quest not claimable'); + + self.claimed.write(user, true); + let reward = self.reward.read(); + + reward + } + } +} + diff --git a/onchain/cairo/src/quests/faction_quest.cairo b/onchain/cairo/src/quests/faction_quest.cairo new file mode 100644 index 00000000..752e1455 --- /dev/null +++ b/onchain/cairo/src/quests/faction_quest.cairo @@ -0,0 +1,68 @@ +#[starknet::contract] +pub mod FactionQuest { + use afk::interfaces::pixel::{IArtPeaceDispatcher, IArtPeaceDispatcherTrait}; + use afk::interfaces::quests::{IAuthorityQuest, IQuest}; + + + use starknet::{ContractAddress, get_caller_address}; + + #[storage] + struct Storage { + art_peace: ContractAddress, + reward: u32, + claimed: LegacyMap, + } + + #[derive(Drop, Serde)] + pub struct FactionQuestInitParams { + pub art_peace: ContractAddress, + pub reward: u32, + } + + #[constructor] + fn constructor(ref self: ContractState, init_params: FactionQuestInitParams) { + self.art_peace.write(init_params.art_peace); + self.reward.write(init_params.reward); + } + + + #[abi(embed_v0)] + impl FactionQuestImpl of IQuest { + fn get_reward(self: @ContractState) -> u32 { + self.reward.read() + } + + fn is_claimable( + self: @ContractState, user: ContractAddress, calldata: Span + ) -> bool { + if self.claimed.read(user) { + return false; + } + + let art_peace_dispatcher = IArtPeaceDispatcher { + contract_address: self.art_peace.read() + }; + + let user_faction = art_peace_dispatcher.get_user_faction(user); + + if user_faction == 0 { + return false; + } + + return true; + } + + + fn claim(ref self: ContractState, user: ContractAddress, calldata: Span) -> u32 { + assert(get_caller_address() == self.art_peace.read(), 'Only ArtPeace can claim quests'); + + assert(self.is_claimable(user, calldata), 'Quest not claimable'); + + self.claimed.write(user, true); + let reward = self.reward.read(); + + reward + } + } +} + diff --git a/onchain/cairo/src/quests/hodl_quest.cairo b/onchain/cairo/src/quests/hodl_quest.cairo new file mode 100644 index 00000000..418f30b8 --- /dev/null +++ b/onchain/cairo/src/quests/hodl_quest.cairo @@ -0,0 +1,68 @@ +#[starknet::contract] +pub mod HodlQuest { + use afk::interfaces::pixel::{IArtPeaceDispatcher, IArtPeaceDispatcherTrait}; + use afk::interfaces::quests::{IQuest}; + use core::traits::TryInto; + use starknet::{ContractAddress, get_caller_address}; + + #[storage] + struct Storage { + art_peace: ContractAddress, + reward: u32, + extra_pixels_needed: u32, + claimed: LegacyMap, + } + + + #[derive(Drop, Serde)] + pub struct HodlQuestInitParams { + pub art_peace: ContractAddress, + pub reward: u32, + pub extra_pixels_needed: u32 + } + + #[constructor] + fn constructor(ref self: ContractState, init_params: HodlQuestInitParams) { + self.art_peace.write(init_params.art_peace); + self.reward.write(init_params.reward); + self.extra_pixels_needed.write(init_params.extra_pixels_needed); + } + + #[abi(embed_v0)] + impl HodlQuestImpl of IQuest { + fn get_reward(self: @ContractState) -> u32 { + self.reward.read() + } + + fn is_claimable( + self: @ContractState, user: ContractAddress, calldata: Span + ) -> bool { + if self.claimed.read(user) { + return false; + } + + let art_peace_dispatcher = IArtPeaceDispatcher { + contract_address: self.art_peace.read() + }; + + let user_extra_pixels = art_peace_dispatcher.get_user_extra_pixels_count(user.into()); + + if user_extra_pixels >= self.extra_pixels_needed.read() { + return true; + } + + false + } + + fn claim(ref self: ContractState, user: ContractAddress, calldata: Span) -> u32 { + assert(get_caller_address() == self.art_peace.read(), 'Only ArtPeace can claim quests'); + + assert(self.is_claimable(user, calldata), 'Quest not claimable'); + + self.claimed.write(user, true); + let reward = self.reward.read(); + + reward + } + } +} diff --git a/onchain/cairo/src/quests/interfaces.cairo b/onchain/cairo/src/quests/interfaces.cairo new file mode 100644 index 00000000..4be9f6bc --- /dev/null +++ b/onchain/cairo/src/quests/interfaces.cairo @@ -0,0 +1,51 @@ +use starknet::ContractAddress; + +#[starknet::interface] +pub trait IQuest { + // Return the reward for the quest. + fn get_reward(self: @TContractState) -> u32; + // Return if the user can claim the quest. + fn is_claimable(self: @TContractState, user: ContractAddress, calldata: Span) -> bool; + // Claim the quest. + fn claim(ref self: TContractState, user: ContractAddress, calldata: Span) -> u32; +} + +#[starknet::interface] +pub trait IAuthorityQuest { + fn is_claimed(self: @TContractState, user: ContractAddress) -> bool; + fn mark_claimable(ref self: TContractState, calldata: Span); +} + +#[starknet::interface] +pub trait IPixelQuest { + fn is_claimed(self: @TContractState, user: starknet::ContractAddress) -> bool; + fn get_pixels_needed(self: @TContractState) -> u32; + fn is_daily(self: @TContractState) -> bool; + fn claim_day(self: @TContractState) -> u32; + fn is_color(self: @TContractState) -> bool; + fn color(self: @TContractState) -> u8; +} + +#[starknet::interface] +pub trait IRainbowQuest { + fn is_claimed(self: @TContractState, user: starknet::ContractAddress) -> bool; +} + + +#[starknet::interface] +pub trait IFactionQuest { + fn is_claimed(self: @TContractState, user: starknet::ContractAddress) -> bool; +} + +#[starknet::interface] +pub trait IUnruggableQuest { + fn is_claimed(self: @TContractState, user: starknet::ContractAddress) -> bool; +} + +#[starknet::interface] +pub trait IUnruggableMemecoin { + // Returns the owner of the unruggable memecoin + fn owner(self: @TState) -> ContractAddress; + // Checks whether token has launched + fn is_launched(self: @TState) -> bool; +} diff --git a/onchain/cairo/src/quests/mod.cairo b/onchain/cairo/src/quests/mod.cairo index 5b1463d6..5811bbd9 100644 --- a/onchain/cairo/src/quests/mod.cairo +++ b/onchain/cairo/src/quests/mod.cairo @@ -1,3 +1,45 @@ +pub use interfaces::{ + IQuest, IAuthorityQuest, IPixelQuest, IRainbowQuest, IUnruggableQuest, IQuestDispatcher, + IQuestDispatcherTrait, IUnruggableMemecoin, IUnruggableMemecoinDispatcher, + IUnruggableMemecoinDispatcherTrait +}; + pub mod quests { + pub mod authority_quest; + pub mod chain_faction_quest; + pub mod faction_quest; + pub mod hodl_quest; + pub mod interfaces; + pub mod nft_quest; + pub mod pixel_quest; + pub mod rainbow_quest; pub mod tap; + pub mod template_quest; + pub mod unruggable_quest; + pub mod username_quest; + pub mod vote_quest; +// pub use interfaces::{ +// IQuest, IAuthorityQuest, IPixelQuest, IRainbowQuest, IUnruggableQuest, IQuestDispatcher, +// IQuestDispatcherTrait, IUnruggableMemecoin, IUnruggableMemecoinDispatcher, +// IUnruggableMemecoinDispatcherTrait +// }; +// #[cfg(test)] +// mod tests { +// pub(crate) mod art_peace; +// pub(crate) mod username_store; +// pub(crate) mod authority_quest; +// pub(crate) mod username_quest; +// pub(crate) mod color_voting; +// pub(crate) mod nft_quest; +// pub(crate) mod hodl_quest; +// pub(crate) mod pixel_quest; +// pub(crate) mod faction_quest; +// pub(crate) mod chain_faction_quest; +// pub(crate) mod rainbow_quest; +// pub(crate) mod template_quest; +// pub(crate) mod unruggable_quest; +// pub(crate) mod vote_quest; +// pub(crate) mod utils; +// } + } diff --git a/onchain/cairo/src/quests/nft_quest.cairo b/onchain/cairo/src/quests/nft_quest.cairo new file mode 100644 index 00000000..9f6bbd35 --- /dev/null +++ b/onchain/cairo/src/quests/nft_quest.cairo @@ -0,0 +1,80 @@ +#[starknet::contract] +pub mod NFTMintQuest { + use afk::interfaces::nfts::{ICanvasNFTStoreDispatcher, ICanvasNFTStoreDispatcherTrait}; + use afk::interfaces::quests::IQuest; + + use starknet::{ContractAddress, get_caller_address}; + + #[storage] + struct Storage { + canvas_nft: ContractAddress, + art_peace: ContractAddress, + reward: u32, + is_daily: bool, + day_index: u32, + claimed: LegacyMap, + } + + #[derive(Drop, Serde)] + pub struct NFTMintQuestInitParams { + pub canvas_nft: ContractAddress, + pub art_peace: ContractAddress, + pub reward: u32, + pub is_daily: bool, + pub day_index: u32, + } + + #[constructor] + fn constructor(ref self: ContractState, init_params: NFTMintQuestInitParams) { + self.canvas_nft.write(init_params.canvas_nft); + self.art_peace.write(init_params.art_peace); + self.reward.write(init_params.reward); + self.is_daily.write(init_params.is_daily); + self.day_index.write(init_params.day_index); + } + + #[abi(embed_v0)] + impl NFTMintQuestImpl of IQuest { + fn get_reward(self: @ContractState) -> u32 { + self.reward.read() + } + + fn is_claimable( + self: @ContractState, user: ContractAddress, calldata: Span + ) -> bool { + if self.claimed.read(user) { + return false; + } + + let token_id_felt = *calldata.at(0); + let token_id: u256 = token_id_felt.into(); + + let nft_store = ICanvasNFTStoreDispatcher { contract_address: self.canvas_nft.read() }; + let token_minter = nft_store.get_nft_minter(token_id); + + if token_minter != user { + return false; + } + + if self.is_daily.read() { + let day_index = nft_store.get_nft_day_index(token_id); + if day_index != self.day_index.read() { + return false; + } + } + + return true; + } + + fn claim(ref self: ContractState, user: ContractAddress, calldata: Span) -> u32 { + assert(get_caller_address() == self.art_peace.read(), 'Only ArtPeace can claim quests'); + + assert(self.is_claimable(user, calldata), 'Quest not claimable'); + + self.claimed.write(user, true); + let reward = self.reward.read(); + + reward + } + } +} diff --git a/onchain/cairo/src/quests/pixel_quest.cairo b/onchain/cairo/src/quests/pixel_quest.cairo new file mode 100644 index 00000000..cfb995b1 --- /dev/null +++ b/onchain/cairo/src/quests/pixel_quest.cairo @@ -0,0 +1,127 @@ +#[starknet::contract] +pub mod PixelQuest { + use afk::interfaces::pixel::{IArtPeaceDispatcher, IArtPeaceDispatcherTrait}; + use afk::interfaces::quests::{IQuest, IPixelQuest}; + + use starknet::{ContractAddress, get_caller_address}; + + #[storage] + struct Storage { + art_peace: IArtPeaceDispatcher, + reward: u32, + claimed: LegacyMap, + pixels_needed: u32, + // Quest types + is_daily: bool, // If the quest is a daily quest + // The day idx the quest can be claimed ( if daily ) + claim_day: u32, + is_color: bool, // If the quest is for a specific color + color: u8, + } + + #[derive(Drop, Serde)] + pub struct PixelQuestInitParams { + pub art_peace: ContractAddress, + pub reward: u32, + pub pixels_needed: u32, + pub is_daily: bool, + pub claim_day: u32, + pub is_color: bool, + pub color: u8, + } + + #[constructor] + fn constructor(ref self: ContractState, init_params: PixelQuestInitParams) { + self.art_peace.write(IArtPeaceDispatcher { contract_address: init_params.art_peace }); + self.reward.write(init_params.reward); + self.pixels_needed.write(init_params.pixels_needed); + self.is_daily.write(init_params.is_daily); + self.claim_day.write(init_params.claim_day); + self.is_color.write(init_params.is_color); + self.color.write(init_params.color); + } + + #[abi(embed_v0)] + impl PixelQuestImpl of IPixelQuest { + fn is_claimed(self: @ContractState, user: ContractAddress) -> bool { + self.claimed.read(user) + } + + fn get_pixels_needed(self: @ContractState) -> u32 { + self.pixels_needed.read() + } + + fn is_daily(self: @ContractState) -> bool { + self.is_daily.read() + } + + fn claim_day(self: @ContractState) -> u32 { + self.claim_day.read() + } + + fn is_color(self: @ContractState) -> bool { + return self.is_color.read(); + } + + fn color(self: @ContractState) -> u8 { + return self.color.read(); + } + } + + #[abi(embed_v0)] + impl PixelQuestImplGeneric of IQuest { + fn get_reward(self: @ContractState) -> u32 { + self.reward.read() + } + + fn is_claimable( + self: @ContractState, user: ContractAddress, calldata: Span + ) -> bool { + let art_peace = self.art_peace.read(); + if self.claimed.read(user) { + return false; + } + + // Daily Pixel Quest + if self.is_daily.read() { + let day = art_peace.get_day(); + if day != self.claim_day.read() { + return false; + } + + if self.is_color.read() { + let placement_count = art_peace + .get_user_pixels_placed_day_color(user, day, self.color.read()); + return placement_count >= self.pixels_needed.read(); + } else { // Daily Pixel Quest + let placement_count = art_peace.get_user_pixels_placed_day(user, day); + return placement_count >= self.pixels_needed.read(); + } + } // Main Pixel Quest + else { + if self.is_color.read() { + let placement_count = art_peace + .get_user_pixels_placed_color(user, self.color.read()); + return placement_count >= self.pixels_needed.read(); + } else { + let placement_count = art_peace.get_user_pixels_placed(user); + return placement_count >= self.pixels_needed.read(); + } + } + } + + fn claim(ref self: ContractState, user: ContractAddress, calldata: Span) -> u32 { + assert( + get_caller_address() == self.art_peace.read().contract_address, + 'Only ArtPeace can claim quests' + ); + + assert(self.is_claimable(user, calldata), 'Quest not claimable'); + + self.claimed.write(user, true); + let reward = self.reward.read(); + + reward + } + } +} diff --git a/onchain/cairo/src/quests/rainbow_quest.cairo b/onchain/cairo/src/quests/rainbow_quest.cairo new file mode 100644 index 00000000..8256dd88 --- /dev/null +++ b/onchain/cairo/src/quests/rainbow_quest.cairo @@ -0,0 +1,76 @@ +#[starknet::contract] +pub mod RainbowQuest { + use afk::interfaces::pixel::{IArtPeaceDispatcher, IArtPeaceDispatcherTrait}; + use afk::interfaces::quests::{IQuest, IRainbowQuest}; + + use starknet::{ContractAddress, get_caller_address}; + + #[storage] + struct Storage { + art_peace: ContractAddress, + reward: u32, + claimed: LegacyMap, + } + + #[derive(Drop, Serde)] + pub struct RainbowQuestInitParams { + pub art_peace: ContractAddress, + pub reward: u32, + } + + #[constructor] + fn constructor(ref self: ContractState, init_params: RainbowQuestInitParams) { + self.art_peace.write(init_params.art_peace); + self.reward.write(init_params.reward); + } + + #[abi(embed_v0)] + impl RainbowQuestImpl of IRainbowQuest { + fn is_claimed(self: @ContractState, user: ContractAddress) -> bool { + self.claimed.read(user) + } + } + + #[abi(embed_v0)] + impl RainbowQuestImplGeneric of IQuest { + fn get_reward(self: @ContractState) -> u32 { + self.reward.read() + } + + fn is_claimable( + self: @ContractState, user: ContractAddress, calldata: Span + ) -> bool { + if self.claimed.read(user) { + return false; + } + + let art_piece = IArtPeaceDispatcher { contract_address: self.art_peace.read() }; + + let mut result = true; + let mut i = 0; + while i < art_piece + .get_color_count() { + if (art_piece.get_user_pixels_placed_color(user, i) == 0) { + result = false; + break; + } + + i += 1; + }; + + result + } + + fn claim(ref self: ContractState, user: ContractAddress, calldata: Span) -> u32 { + assert(get_caller_address() == self.art_peace.read(), 'Only ArtPeace can claim quests'); + + assert(self.is_claimable(user, calldata), 'Quest not claimable'); + + self.claimed.write(user, true); + let reward = self.reward.read(); + + reward + } + } +} + diff --git a/onchain/cairo/src/quests/template_quest.cairo b/onchain/cairo/src/quests/template_quest.cairo new file mode 100644 index 00000000..01504eb3 --- /dev/null +++ b/onchain/cairo/src/quests/template_quest.cairo @@ -0,0 +1,66 @@ +#[starknet::contract] +pub mod TemplateQuest { + use afk::interfaces::pixel_template::{ITemplateStoreDispatcher, ITemplateStoreDispatcherTrait}; + use afk::interfaces::quests::IQuest; + + use starknet::{ContractAddress, get_caller_address}; + + #[storage] + struct Storage { + art_peace: ContractAddress, + reward: u32, + claimed: LegacyMap, + } + + #[derive(Drop, Serde)] + pub struct TemplateQuestInitParams { + pub art_peace: ContractAddress, + pub reward: u32, + } + + #[constructor] + fn constructor(ref self: ContractState, init_params: TemplateQuestInitParams) { + self.art_peace.write(init_params.art_peace); + self.reward.write(init_params.reward); + } + + #[abi(embed_v0)] + impl TemplateQuest of IQuest { + fn get_reward(self: @ContractState) -> u32 { + self.reward.read() + } + + fn is_claimable( + self: @ContractState, user: ContractAddress, calldata: Span + ) -> bool { + if self.claimed.read(user) { + return false; + } + + let template_id_felt = *calldata.at(0); + let template_id: u32 = template_id_felt.try_into().unwrap(); + + let template_store = ITemplateStoreDispatcher { + contract_address: self.art_peace.read() + }; + let template = template_store.get_template(template_id); + + if template.creator != user { + return false; + } + + return true; + } + + fn claim(ref self: ContractState, user: ContractAddress, calldata: Span) -> u32 { + assert(get_caller_address() == self.art_peace.read(), 'Only ArtPeace can claim quests'); + + assert(self.is_claimable(user, calldata), 'Quest not claimable'); + + self.claimed.write(user, true); + let reward = self.reward.read(); + + reward + } + } +} diff --git a/onchain/cairo/src/quests/unruggable_quest.cairo b/onchain/cairo/src/quests/unruggable_quest.cairo new file mode 100644 index 00000000..f7ff841c --- /dev/null +++ b/onchain/cairo/src/quests/unruggable_quest.cairo @@ -0,0 +1,73 @@ +#[starknet::contract] +pub mod UnruggableQuest { + use afk::interfaces::pixel::{IArtPeaceDispatcher, IArtPeaceDispatcherTrait}; + use afk::interfaces::quests::{ + IQuest, IUnruggableQuest, IUnruggableMemecoinDispatcher, IUnruggableMemecoinDispatcherTrait + }; + + use starknet::{ContractAddress, get_caller_address}; + + #[storage] + struct Storage { + art_peace: ContractAddress, + reward: u32, + claimed: LegacyMap, + } + + #[derive(Drop, Serde)] + pub struct UnruggableQuestInitParams { + pub art_peace: ContractAddress, + pub reward: u32, + } + + #[constructor] + fn constructor(ref self: ContractState, init_params: UnruggableQuestInitParams) { + self.art_peace.write(init_params.art_peace); + self.reward.write(init_params.reward); + } + + #[abi(embed_v0)] + impl UnruggableQuestImpl of IUnruggableQuest { + fn is_claimed(self: @ContractState, user: ContractAddress) -> bool { + self.claimed.read(user) + } + } + + #[abi(embed_v0)] + impl UnruggableQuestImplGeneric of IQuest { + fn get_reward(self: @ContractState) -> u32 { + self.reward.read() + } + + fn is_claimable( + self: @ContractState, user: ContractAddress, calldata: Span + ) -> bool { + if self.claimed.read(user) { + return false; + } + + let coin_address_as_felt252: felt252 = *calldata.at(0); + let coin = IUnruggableMemecoinDispatcher { + contract_address: coin_address_as_felt252.try_into().unwrap() + }; + + if coin.is_launched() != true { + return false; + } + + true + } + + fn claim(ref self: ContractState, user: ContractAddress, calldata: Span) -> u32 { + assert(get_caller_address() == self.art_peace.read(), 'Only ArtPeace can claim quests'); + + assert(self.is_claimable(user, calldata), 'Quest not claimable'); + + self.claimed.write(user, true); + let reward = self.reward.read(); + + reward + } + } +} + diff --git a/onchain/cairo/src/quests/username_quest.cairo b/onchain/cairo/src/quests/username_quest.cairo new file mode 100644 index 00000000..ad776e4a --- /dev/null +++ b/onchain/cairo/src/quests/username_quest.cairo @@ -0,0 +1,64 @@ +#[starknet::contract] +pub mod UsernameQuest { + use afk::interfaces::quests::{IQuest}; + use afk::interfaces::username_store::{IUsernameStoreDispatcher, IUsernameStoreDispatcherTrait,}; + use starknet::{ContractAddress, get_caller_address}; + + #[storage] + struct Storage { + art_peace: ContractAddress, + reward: u32, + username_store: IUsernameStoreDispatcher, + claimed: LegacyMap, + } + + + #[derive(Drop, Serde)] + pub struct UsernameQuestInitParams { + pub art_peace: ContractAddress, + pub reward: u32, + pub username_store: ContractAddress, + } + + #[constructor] + fn constructor(ref self: ContractState, init_params: UsernameQuestInitParams) { + self.art_peace.write(init_params.art_peace); + self.reward.write(init_params.reward); + self + .username_store + .write(IUsernameStoreDispatcher { contract_address: init_params.username_store }); + } + + #[abi(embed_v0)] + impl UsernameQuestImpl of IQuest { + fn get_reward(self: @ContractState) -> u32 { + self.reward.read() + } + + fn is_claimable( + self: @ContractState, user: ContractAddress, calldata: Span + ) -> bool { + if self.claimed.read(user) { + return false; + } + + let username = self.username_store.read().get_username(user); + if username == 0 { + return false; + } + + return true; + } + + fn claim(ref self: ContractState, user: ContractAddress, calldata: Span) -> u32 { + assert(get_caller_address() == self.art_peace.read(), 'Only ArtPeace can claim quests'); + + assert(self.is_claimable(user, calldata), 'Quest not claimable'); + + self.claimed.write(user, true); + let reward = self.reward.read(); + + reward + } + } +} diff --git a/onchain/cairo/src/quests/vote_quest.cairo b/onchain/cairo/src/quests/vote_quest.cairo new file mode 100644 index 00000000..7fa8f29e --- /dev/null +++ b/onchain/cairo/src/quests/vote_quest.cairo @@ -0,0 +1,67 @@ +#[starknet::contract] +pub mod VoteQuest { + use afk::interfaces::pixel::{IArtPeaceDispatcher, IArtPeaceDispatcherTrait}; + use afk::interfaces::quests::{IQuest}; + use starknet::{ContractAddress, get_caller_address}; + + #[storage] + struct Storage { + art_peace: ContractAddress, + reward: u32, + day_index: u32, + claimed: LegacyMap, + } + + #[derive(Drop, Serde)] + pub struct VoteQuestInitParams { + pub art_peace: ContractAddress, + pub reward: u32, + pub day_index: u32 + } + + #[constructor] + fn constructor(ref self: ContractState, init_params: VoteQuestInitParams) { + self.art_peace.write(init_params.art_peace); + self.reward.write(init_params.reward); + self.day_index.write(init_params.day_index); + } + + #[abi(embed_v0)] + impl VoteQuestImpl of IQuest { + fn get_reward(self: @ContractState) -> u32 { + self.reward.read() + } + + fn is_claimable( + self: @ContractState, user: ContractAddress, calldata: Span + ) -> bool { + if self.claimed.read(user) { + return false; + } + + let day_index = self.day_index.read(); + let art_peace_dispatcher = IArtPeaceDispatcher { + contract_address: self.art_peace.read() + }; + + // 0, if user has not voted for any color + let user_vote: u8 = art_peace_dispatcher.get_user_vote(user, day_index); + if user_vote == 0 { + return false; + } else { + return true; + } + } + + fn claim(ref self: ContractState, user: ContractAddress, calldata: Span) -> u32 { + assert(get_caller_address() == self.art_peace.read(), 'Only ArtPeace can claim quests'); + + assert(self.is_claimable(user, calldata), 'Quest not claimable'); + + self.claimed.write(user, true); + let reward = self.reward.read(); + + reward + } + } +} diff --git a/onchain/cairo/src/social/namespace.cairo b/onchain/cairo/src/social/namespace.cairo index 6d9d055d..2823ee6a 100644 --- a/onchain/cairo/src/social/namespace.cairo +++ b/onchain/cairo/src/social/namespace.cairo @@ -310,8 +310,8 @@ mod tests { tags: "[]", content: linked_wallet.clone(), sig: Signature { - r: 0x8ffbabf63d0fd526dffb8c04d04a216bb03743fae22826a2b42005d478c48360_u256, - s: 0x6aa0f5295635d03d6d3f61aaf7f4163175ed1a9001550b9da4c0a3a6098c0fab_u256, + r: 0x4e04216ca171673375916f12e1a56e00dca1d39e44207829d659d06f3a972d6f_u256, + s: 0xa16bc69fab00104564b9dad050a29af4d2380c229de984e49ad125fe29b5be8e_u256, // r: 0x051b6d408b709d29b6ef55b1aa74d31a9a265c25b0b91c2502108b67b29c0d5c_u256, // s: 0xe31f5691af0e950eb8697fdbbd464ba725b2aaf7e5885c4eaa30a1e528269793_u256 } @@ -366,7 +366,7 @@ mod tests { } #[test] - #[should_panic(expected: 'can\'t verify signature')] + #[should_panic()] fn link_incorrect_signature() { let (_, _, sender_address, namespace, fail_request_linked_wallet_to_caller) = request_fixture(); @@ -384,7 +384,7 @@ mod tests { } #[test] - #[should_panic(expected: 'can\'t verify signature')] + #[should_panic()] fn link_incorrect_signature_link_to() { let (request, _, sender_address, namespace, _) = request_fixture(); cheat_caller_address_global(sender_address); @@ -392,7 +392,7 @@ mod tests { start_cheat_caller_address(namespace.contract_address, sender_address); let request_test_failed_sig = SocialRequest { sig: Signature { - r: 0x2570a9a0c92c180bd4ac826c887e63844b043e3b65da71a857d2aa29e7cd3a4e_u256, + r: 0x2570a9a0c92c180bd4ac826c887e63844b043e3b65da71a857d2aa29e7cd3a5e_u256, s: 0x1c0c0a8b7a8330b6b8915985c9cd498a407587213c2e7608e7479b4ef966605f_u256, }, ..request, diff --git a/onchain/cairo/src/templates.cairo b/onchain/cairo/src/templates.cairo new file mode 100644 index 00000000..b9a16841 --- /dev/null +++ b/onchain/cairo/src/templates.cairo @@ -0,0 +1 @@ +pub mod templates; diff --git a/onchain/cairo/src/templates/mod.cairo b/onchain/cairo/src/templates/mod.cairo new file mode 100644 index 00000000..766d4648 --- /dev/null +++ b/onchain/cairo/src/templates/mod.cairo @@ -0,0 +1,3 @@ +pub mod templates { + pub mod template; +} diff --git a/onchain/cairo/src/templates/template.cairo b/onchain/cairo/src/templates/template.cairo new file mode 100644 index 00000000..ef0adfef --- /dev/null +++ b/onchain/cairo/src/templates/template.cairo @@ -0,0 +1,100 @@ +#[starknet::component] +pub mod TemplateStoreComponent { + use afk::interfaces::pixel_template::{ITemplateStore, TemplateMetadata}; + use core::num::traits::Zero; + use openzeppelin::token::erc20::interface::{IERC20Dispatcher, IERC20DispatcherTrait}; + use starknet::{ContractAddress, get_caller_address}; + + #[storage] + struct Storage { + templates_count: u32, + // Map: template_id -> template_metadata + templates: LegacyMap::, + // Map: template_id -> is_completed + completed_templates: LegacyMap::, + } + + #[event] + #[derive(Drop, starknet::Event)] + pub enum Event { + TemplateAdded: TemplateAdded, + TemplateCompleted: TemplateCompleted, + } + + #[derive(Drop, starknet::Event)] + struct TemplateAdded { + #[key] + id: u32, + metadata: TemplateMetadata, + } + + #[derive(Drop, starknet::Event)] + struct TemplateCompleted { + #[key] + id: u32, + // TODO: Users rewarded, ... + } + + #[embeddable_as(TemplateStoreImpl)] + impl TemplateStore< + TContractState, +HasComponent + > of ITemplateStore> { + fn get_templates_count(self: @ComponentState) -> u32 { + self.templates_count.read() + } + + fn get_template( + self: @ComponentState, template_id: u32 + ) -> TemplateMetadata { + self.templates.read(template_id) + } + + fn get_template_hash(self: @ComponentState, template_id: u32) -> felt252 { + let metadata: TemplateMetadata = self.templates.read(template_id); + + metadata.hash + } + + // TODO: Return idx of the template? + fn add_template( + ref self: ComponentState, template_metadata: TemplateMetadata + ) { + let template_id = self.templates_count.read(); + self.templates.write(template_id, template_metadata); + self.templates_count.write(template_id + 1); + + if !template_metadata.reward_token.is_zero() && template_metadata.reward != 0 { + self.deposit(template_metadata.reward_token, template_metadata.reward); + } + + self.emit(TemplateAdded { id: template_id, metadata: template_metadata }); + } + + fn is_template_complete(self: @ComponentState, template_id: u32) -> bool { + self.completed_templates.read(template_id) + } + } + + #[generate_trait] + impl InternalImpl< + TContractState, +HasComponent + > of InternalTrait { + fn deposit( + ref self: ComponentState, + reward_token: ContractAddress, + reward_amount: u256 + ) { + let caller_address = get_caller_address(); + let contract_address = starknet::get_contract_address(); + assert(!get_caller_address().is_zero(), 'Invalid caller'); + + let erc20_dispatcher = IERC20Dispatcher { contract_address: reward_token }; + let allowance = erc20_dispatcher.allowance(caller_address, contract_address); + assert(allowance >= reward_amount, 'Insufficient allowance'); + + let success = erc20_dispatcher + .transfer_from(caller_address, contract_address, reward_amount); + assert(success, 'Transfer failed'); + } + } +} diff --git a/onchain/cairo/src/tests/vault_tests.cairo b/onchain/cairo/src/tests/vault_tests.cairo index a7a9814f..cc0937ae 100644 --- a/onchain/cairo/src/tests/vault_tests.cairo +++ b/onchain/cairo/src/tests/vault_tests.cairo @@ -108,7 +108,7 @@ mod vault_test { let mut spy = spy_events(SpyOn::One(vault_dispatcher.contract_address)); let amount = 200; - // set permited token + // set permitted token start_cheat_caller_address(vault_dispatcher.contract_address, ADMIN()); vault_dispatcher.set_token_permitted(wbtc_dispatcher.contract_address, 2_u256, true, 1_64); @@ -186,7 +186,7 @@ mod vault_test { } #[test] - #[should_panic(expected: ('Non permited token',))] + #[should_panic(expected: ('Non permitted token',))] fn test_mint_by_token_with_non_permitted_token() { let (vault_dispatcher, wbtc_dispatcher, _,) = setup(); @@ -195,7 +195,7 @@ mod vault_test { } #[test] - #[should_panic(expected: ('Non permited token',))] + #[should_panic(expected: ('Non permitted token',))] fn test_withdraw_coin_by_token_with_non_permitted_token() { let (vault_dispatcher, wbtc_dispatcher, _,) = setup(); @@ -218,7 +218,7 @@ mod vault_test { } #[test] - #[should_panic(expected: ('Non permited token',))] + #[should_panic(expected: ('Non permitted token',))] fn test_get_token_ratio_with_non_permitted_token() { let (vault_dispatcher, _, abtc_dispatcher,) = setup(); diff --git a/onchain/foundry.toml b/onchain/foundry.toml index 637d9d56..5d10f4ec 100644 --- a/onchain/foundry.toml +++ b/onchain/foundry.toml @@ -2,7 +2,9 @@ src = "solidity_contracts/src" test = "solidity_contracts/test" out = "out" -libs = ["solidity_contracts/lib", "lib/kakarot-rpc"] +libs = ["solidity_contracts/lib"] +evm_version= "cancun" # See more config options https://github.com/foundry-rs/foundry/blob/master/crates/config/README.md#all-options + diff --git a/onchain/solidity_contracts/lib/forge-std b/onchain/solidity_contracts/lib/forge-std index 58d30519..1714bee7 160000 --- a/onchain/solidity_contracts/lib/forge-std +++ b/onchain/solidity_contracts/lib/forge-std @@ -1 +1 @@ -Subproject commit 58d30519826c313ce47345abedfdc07679e944d1 +Subproject commit 1714bee72e286e73f76e320d110e0eaf5c4e649d diff --git a/onchain/solidity_contracts/lib/openzeppelin-contracts b/onchain/solidity_contracts/lib/openzeppelin-contracts new file mode 160000 index 00000000..1edc2ae0 --- /dev/null +++ b/onchain/solidity_contracts/lib/openzeppelin-contracts @@ -0,0 +1 @@ +Subproject commit 1edc2ae004974ebf053f4eba26b45469937b9381 diff --git a/onchain/solidity_contracts/lib/openzeppelin-contracts-upgradeable b/onchain/solidity_contracts/lib/openzeppelin-contracts-upgradeable new file mode 160000 index 00000000..22489db1 --- /dev/null +++ b/onchain/solidity_contracts/lib/openzeppelin-contracts-upgradeable @@ -0,0 +1 @@ +Subproject commit 22489db15621b9a42ebddb1facade6962034e9b9 diff --git a/onchain/solidity_contracts/src/defi/Vault.sol b/onchain/solidity_contracts/src/defi/Vault.sol index eae7eaba..f7772d7e 100644 --- a/onchain/solidity_contracts/src/defi/Vault.sol +++ b/onchain/solidity_contracts/src/defi/Vault.sol @@ -1,18 +1,150 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.0; -contract Vault { - mapping (address => uint) name; - mapping (address => Deposit) depositUsers; +import "@openzeppelin/contracts-upgradeable/token/ERC20/ERC20Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/token/ERC20/extensions/ERC4626Upgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/access/AccessControlUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/Initializable.sol"; +import "@openzeppelin/contracts-upgradeable/proxy/utils/UUPSUpgradeable.sol"; +import "@openzeppelin/contracts-upgradeable/utils/PausableUpgradeable.sol"; +import "@openzeppelin/contracts/token/ERC20/IERC20.sol"; - struct Deposit { - uint256 deposit; - uint256 withdraw; - address owner; - +contract ABTCVault is + Initializable, + ERC20Upgradeable, + ERC4626Upgradeable, + AccessControlUpgradeable, + UUPSUpgradeable, + PausableUpgradeable +{ + function decimals() + public + view + virtual + override(ERC20Upgradeable, ERC4626Upgradeable) + returns (uint8) + { + return 18; } - constructor() { - + bytes32 public constant MINTER_ROLE = keccak256("MINTER_ROLE"); + bytes32 public constant UPGRADER_ROLE = keccak256("UPGRADER_ROLE"); + bytes32 public constant PAUSER_ROLE = keccak256("PAUSER_ROLE"); + + struct WrappedBTC { + bool isPermitted; + uint256 poolingTimestamp; + } + + mapping(address => WrappedBTC) public wrappedBTCTokens; + mapping(address => mapping(address => uint256)) public userDeposits; + + event TokenPermissionSet( + address indexed token, + bool isPermitted, + uint256 poolingTimestamp + ); + event Deposited( + address indexed user, + address indexed token, + uint256 amount, + uint256 mintedAmount + ); + event Withdrawn( + address indexed user, + address indexed token, + uint256 amount, + uint256 burnedAmount + ); + + function initialize( + address _admin, + address _underlying + ) public initializer { + __ERC20_init("Aggregated Bitcoin", "aBTC"); + __ERC4626_init(IERC20(_underlying)); + __AccessControl_init(); + __UUPSUpgradeable_init(); + __Pausable_init(); + + _grantRole(DEFAULT_ADMIN_ROLE, _admin); + _grantRole(MINTER_ROLE, _admin); + _grantRole(UPGRADER_ROLE, _admin); + _grantRole(PAUSER_ROLE, _admin); + } + + function setWrappedBTCToken( + address _token, + bool _isPermitted, + uint256 ratio, + uint256 _poolingTimestamp + ) external onlyRole(DEFAULT_ADMIN_ROLE) { + wrappedBTCTokens[_token] = WrappedBTC(_isPermitted, _poolingTimestamp); + emit TokenPermissionSet(_token, _isPermitted, _poolingTimestamp); + } + + function deposit( + address _wrappedBTCToken, + uint256 _amount + ) external whenNotPaused { + WrappedBTC memory wrappedToken = wrappedBTCTokens[_wrappedBTCToken]; + require(wrappedToken.isPermitted, "Token not permitted"); + + IERC20(_wrappedBTCToken).transferFrom( + msg.sender, + address(this), + _amount + ); + _mint(msg.sender, _amount); // 1:1 minting without fees + + userDeposits[msg.sender][_wrappedBTCToken] += _amount; + + emit Deposited(msg.sender, _wrappedBTCToken, _amount, _amount); + } + + function withdraw( + address _wrappedBTCToken, + uint256 _aBTCAmount + ) external whenNotPaused { + WrappedBTC memory wrappedToken = wrappedBTCTokens[_wrappedBTCToken]; + require(wrappedToken.isPermitted, "Token not permitted"); + + require( + userDeposits[msg.sender][_wrappedBTCToken] >= _aBTCAmount, + "Insufficient deposit" + ); + + _burn(msg.sender, _aBTCAmount); + IERC20(_wrappedBTCToken).transfer(msg.sender, _aBTCAmount); // 1:1 withdrawal without fees + + userDeposits[msg.sender][_wrappedBTCToken] -= _aBTCAmount; + + emit Withdrawn(msg.sender, _wrappedBTCToken, _aBTCAmount, _aBTCAmount); + } + + function mint( + address _to, + uint256 _amount + ) external onlyRole(MINTER_ROLE) whenNotPaused { + _mint(_to, _amount); } + + function burn( + address _from, + uint256 _amount + ) external onlyRole(MINTER_ROLE) whenNotPaused { + _burn(_from, _amount); + } + + function pause() external onlyRole(PAUSER_ROLE) { + _pause(); + } + + function unpause() external onlyRole(PAUSER_ROLE) { + _unpause(); + } + + function _authorizeUpgrade( + address newImplementation + ) internal override onlyRole(UPGRADER_ROLE) {} } \ No newline at end of file diff --git a/onchain/solidity_contracts/src/launchpad/LaunchpadPumpDualVM.sol b/onchain/solidity_contracts/src/launchpad/LaunchpadPumpDualVM.sol index 27750c74..df5c1e48 100644 --- a/onchain/solidity_contracts/src/launchpad/LaunchpadPumpDualVM.sol +++ b/onchain/solidity_contracts/src/launchpad/LaunchpadPumpDualVM.sol @@ -1,6 +1,10 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.0; +import {CairoLib} from "kakarot-lib/CairoLib.sol"; + +using CairoLib for uint256; + contract LaunchpadPumpDualVM { /// @dev The address of the cairo contract to call uint256 immutable starknetLaunchpad; @@ -69,20 +73,57 @@ contract LaunchpadPumpDualVM { function getLaunchPump(uint256 tokenAddress) public { uint256[] memory tokenAddressCalldata = new uint256[](1); - tokenAddressCalldata[0] = uint256(uint160(from)); + tokenAddressCalldata[0] = uint256(uint160(tokenAddress)); uint256 tokenStarknetAddress = - abi.decode(kakarot.staticcallCairo("compute_starknet_address", tokenAddressCalldata), (uint256)); + abi.decode(starknetLaunchpad.staticcallCairo("compute_starknet_address", tokenAddressCalldata), (uint256)); // call launch that sent struct // todo how do it? } - function createToken() public { + + /** */ + function createToken(address recipient, + bytes calldata symbol, + bytes calldata name, + uint256 initialSupply, + bytes calldata contractAddressSalt + ) public { + + uint256[] memory recipientAddressCalldata = new uint256[](1); + recipientAddressCalldata[0] = uint256(uint160(recipient)); + uint256 recipientStarknetAddress = + abi.decode(starknetLaunchpad.staticcallCairo("compute_starknet_address", recipientAddressCalldata), (uint256)); + + uint128 amountLow = uint128(initialSupply); + uint128 amountHigh = uint128(initialSupply >> 128); + + uint256[] memory createTokenCallData = new uint256[](6); + createTokenCallData[0] = recipientStarknetAddress; + // Decode the first 32 bytes (a uint256 is 32 bytes) + uint256 symbolResult = abi.decode(symbol, (uint256)); + uint256 nameResult = abi.decode(name, (uint256)); + uint256 contractAddressSaltResult = abi.decode(contractAddressSalt, (uint256)); + + createTokenCallData[1] = uint(symbolResult); + createTokenCallData[2] = uint(nameResult); + createTokenCallData[3] = uint256(amountLow); + createTokenCallData[4] = uint256(amountHigh); + createTokenCallData[5] = uint256(contractAddressSaltResult); + + starknetLaunchpad.callCairo(FUNCTION_SELECTOR_CREATE_TOKEN, createTokenCallData); } - function createAndLaunchToken() public { + function createAndLaunchToken( + address recipient, + bytes calldata symbol, + bytes calldata name, + uint256 initialSupply, + bytes calldata contractAddressSalt + ) public { + } diff --git a/onchain/solidity_contracts/src/nostr/Namespace.sol b/onchain/solidity_contracts/src/nostr/Namespace.sol index f856e0c1..11ff1a90 100644 --- a/onchain/solidity_contracts/src/nostr/Namespace.sol +++ b/onchain/solidity_contracts/src/nostr/Namespace.sol @@ -1,9 +1,14 @@ // SPDX-License-Identifier: MIT pragma solidity >=0.8.0; + +import {CairoLib} from "kakarot-lib/CairoLib.sol"; + +using CairoLib for uint256; + contract Namespace { - /// @dev The address of the starknet token to call + /// @dev The address of the starknet token to call uint256 immutable namespaceAddress; constructor(uint256 _namespaceAddress) { @@ -18,12 +23,12 @@ contract Namespace { kakarotCallData[0] = uint256(uint160(userAddress)); uint256 userStarknetAddress = - abi.decode(kakarot.staticcallCairo("compute_starknet_address", kakarotCallData), (uint256)); + abi.decode(namespaceAddress.staticcallCairo("compute_starknet_address", kakarotCallData), (uint256)); uint256[] memory addressOfCallData = new uint256[](1); addressOfCallData[0] = userStarknetAddress; - bytes memory returnData = namespaceAddress.staticcallCairo("get_nostr_by_sn_default", balanceOfCallData); - return abi.decode(returnData, (uint256)); + bytes memory returnData = namespaceAddress.staticcallCairo("get_nostr_by_sn_default", addressOfCallData); + // return abi.decode(returnData, (uint256)); } @@ -34,12 +39,12 @@ contract Namespace { kakarotCallData[0] = uint256(uint160(nostrAddress)); uint256 userStarknetAddress = - abi.decode(kakarot.staticcallCairo("compute_starknet_address", kakarotCallData), (uint256)); + abi.decode(namespaceAddress.staticcallCairo("compute_starknet_address", kakarotCallData), (uint256)); uint256[] memory addressOfCallData = new uint256[](1); addressOfCallData[0] = userStarknetAddress; - bytes memory returnData = namespaceAddress.staticcallCairo("get_sn_by_nostr_default", balanceOfCallData); - return abi.decode(returnData, (uint256)); + bytes memory returnData = namespaceAddress.staticcallCairo("get_sn_by_nostr_default", addressOfCallData); + // return abi.decode(returnData, (uint256)); } function linkNostrAddress() public { diff --git a/onchain/solidity_contracts/test/Vault.t.sol b/onchain/solidity_contracts/test/Vault.t.sol new file mode 100644 index 00000000..002c1adc --- /dev/null +++ b/onchain/solidity_contracts/test/Vault.t.sol @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: MIT +pragma solidity >=0.8.0; + +import "forge-std/Test.sol"; +import "../src/defi/Vault.sol"; +import "@openzeppelin/contracts/token/ERC20/ERC20.sol"; + +contract MockWrappedBTC is ERC20 { + constructor(string memory name, string memory symbol) ERC20(name, symbol) { + _mint(msg.sender, 1000000 * 10 ** 18); + } +} + +contract ABTCVaultTest is Test { + ABTCVault public vault; + MockWrappedBTC public wbtc; + MockWrappedBTC public tbtc; + address public admin; + address public user1; + address public user2; + + function setUp() public { + admin = address(this); + user1 = address(0x1); + user2 = address(0x2); + + wbtc = new MockWrappedBTC("Wrapped BTC", "WBTC"); + tbtc = new MockWrappedBTC("tBTC", "TBTC"); + + // vault = new ABTCVault(); + vault = new ABTCVault(); + vault.initialize(admin, address(wbtc)); + + vm.label(address(vault), "ABTCVault"); + vm.label(address(wbtc), "WBTC"); + vm.label(address(tbtc), "TBTC"); + vm.label(user1, "User1"); + vm.label(user2, "User2"); + } + + function testInitialization() public { + assertEq(vault.name(), "Aggregated Bitcoin"); + assertEq(vault.symbol(), "aBTC"); + assertEq(vault.decimals(), 18); + assertTrue(vault.hasRole(vault.DEFAULT_ADMIN_ROLE(), admin)); + assertTrue(vault.hasRole(vault.MINTER_ROLE(), admin)); + assertTrue(vault.hasRole(vault.UPGRADER_ROLE(), admin)); + assertTrue(vault.hasRole(vault.PAUSER_ROLE(), admin)); + } + + function testSetWrappedBTCToken() public { + vault.setWrappedBTCToken(address(wbtc), true, 1e18, block.timestamp); + (bool isPermitted, uint256 poolingTimestamp) = vault.wrappedBTCTokens( + address(wbtc) + ); + assertTrue(isPermitted); + assertEq(poolingTimestamp, block.timestamp); + } + + function testSetWrappedBTCTokenUnauthorized() public { + vm.prank(user1); + vm.expectRevert( + "AccessControl: account 0x0000000000000000000000000000000000000001 is missing role 0x0000000000000000000000000000000000000000000000000000000000000000" + ); + vault.setWrappedBTCToken(address(wbtc), true, 1e18, block.timestamp); + } + + function testDeposit() public { + vault.setWrappedBTCToken(address(wbtc), true, 1e18, block.timestamp); + uint256 depositAmount = 100 * 1e18; + wbtc.approve(address(vault), depositAmount); + vault.deposit(address(wbtc), depositAmount); + assertEq(vault.balanceOf(address(this)), depositAmount); + assertEq(wbtc.balanceOf(address(vault)), depositAmount); + } + + function testDepositUnauthorizedToken() public { + uint256 depositAmount = 100 * 1e18; + tbtc.approve(address(vault), depositAmount); + vm.expectRevert("Token not permitted"); + vault.deposit(address(tbtc), depositAmount); + } + + function testWithdraw() public { + vault.setWrappedBTCToken(address(wbtc), true, 1e18, block.timestamp); + uint256 depositAmount = 100 * 1e18; + wbtc.approve(address(vault), depositAmount); + vault.deposit(address(wbtc), depositAmount); + + uint256 withdrawAmount = 50 * 1e18; + vault.withdraw(address(wbtc), withdrawAmount); + assertEq( + vault.balanceOf(address(this)), + depositAmount - withdrawAmount + ); + assertEq( + wbtc.balanceOf(address(this)), + 1000000 * 1e18 - depositAmount + withdrawAmount + ); + } + + function testWithdrawInsufficientBalance() public { + vault.setWrappedBTCToken(address(wbtc), true, 1e18, block.timestamp); + uint256 depositAmount = 100 * 1e18; + wbtc.approve(address(vault), depositAmount); + vault.deposit(address(wbtc), depositAmount); + + uint256 withdrawAmount = 150 * 1e18; + vm.expectRevert("Insufficient deposit"); + vault.withdraw(address(wbtc), withdrawAmount); + } + + function testMint() public { + uint256 mintAmount = 100 * 1e18; + vault.mint(user1, mintAmount); + assertEq(vault.balanceOf(user1), mintAmount); + } + + function testMintUnauthorized() public { + uint256 mintAmount = 100 * 1e18; + vm.prank(user1); + vm.expectRevert( + "AccessControl: account 0x0000000000000000000000000000000000000001 is missing role 0x9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a6" + ); + vault.mint(user2, mintAmount); + } + + function testBurn() public { + uint256 mintAmount = 100 * 1e18; + vault.mint(user1, mintAmount); + vault.burn(user1, mintAmount); + assertEq(vault.balanceOf(user1), 0); + } + + function testBurnUnauthorized() public { + uint256 mintAmount = 100 * 1e18; + vault.mint(user1, mintAmount); + vm.prank(user1); + vm.expectRevert( + "AccessControl: account 0x0000000000000000000000000000000000000001 is missing role 0x9f2df0fed2c77648de5860a4cc508cd0818c85b8b8a1ab4ceeef8d981c8956a6" + ); + vault.burn(user1, mintAmount); + } + + function testPause() public { + vault.pause(); + assertTrue(vault.paused()); + } + + function testPauseUnauthorized() public { + vm.prank(user1); + vm.expectRevert( + "AccessControl: account 0x0000000000000000000000000000000000000001 is missing role 0x65d7a28e3265b37a6474929f336521b332c1681b933f6cb9f3376673440d862a" + ); + vault.pause(); + } + + function testUnpause() public { + vault.pause(); + vault.unpause(); + assertFalse(vault.paused()); + } + + function testDepositWhenPaused() public { + vault.setWrappedBTCToken(address(wbtc), true, 1e18, block.timestamp); + vault.pause(); + uint256 depositAmount = 100 * 1e18; + wbtc.approve(address(vault), depositAmount); + vm.expectRevert("Pausable: paused"); + vault.deposit(address(wbtc), depositAmount); + } + + function testWithdrawWhenPaused() public { + vault.setWrappedBTCToken(address(wbtc), true, 1e18, block.timestamp); + uint256 depositAmount = 100 * 1e18; + wbtc.approve(address(vault), depositAmount); + vault.deposit(address(wbtc), depositAmount); + vault.pause(); + vm.expectRevert("Pausable: paused"); + vault.withdraw(address(wbtc), depositAmount); + } + + function testMultipleDepositsAndWithdrawals() public { + vault.setWrappedBTCToken(address(wbtc), true, 1e18, block.timestamp); + vault.setWrappedBTCToken(address(tbtc), true, 1e18, block.timestamp); + + uint256 wbtcAmount = 100 * 1e18; + uint256 tbtcAmount = 50 * 1e18; + + wbtc.approve(address(vault), wbtcAmount); + tbtc.approve(address(vault), tbtcAmount); + + vault.deposit(address(wbtc), wbtcAmount); + vault.deposit(address(tbtc), tbtcAmount); + + assertEq(vault.balanceOf(address(this)), wbtcAmount + tbtcAmount); + + vault.withdraw(address(wbtc), 30 * 1e18); + vault.withdraw(address(tbtc), 20 * 1e18); + + assertEq(vault.balanceOf(address(this)), 100 * 1e18); + assertEq(wbtc.balanceOf(address(vault)), 70 * 1e18); + assertEq(tbtc.balanceOf(address(vault)), 30 * 1e18); + } + + function testDifferentRatios() public { + vault.setWrappedBTCToken(address(wbtc), true, 1e18, block.timestamp); // 1:1 ratio + vault.setWrappedBTCToken(address(tbtc), true, 2e18, block.timestamp); // 2:1 ratio + + uint256 wbtcAmount = 100 * 1e18; + uint256 tbtcAmount = 50 * 1e18; + + wbtc.approve(address(vault), wbtcAmount); + tbtc.approve(address(vault), tbtcAmount); + + vault.deposit(address(wbtc), wbtcAmount); + vault.deposit(address(tbtc), tbtcAmount); + + assertEq(vault.balanceOf(address(this)), wbtcAmount + (tbtcAmount * 2)); + } +} diff --git a/packages/afk_nostr_sdk/src/hooks/group/private/useAddMember.ts b/packages/afk_nostr_sdk/src/hooks/group/private/useAddMember.ts index c8df41be..ccbb20ff 100644 --- a/packages/afk_nostr_sdk/src/hooks/group/private/useAddMember.ts +++ b/packages/afk_nostr_sdk/src/hooks/group/private/useAddMember.ts @@ -1,17 +1,32 @@ +import {NDKEvent, NDKKind} from '@nostr-dev-kit/ndk'; import {useMutation} from '@tanstack/react-query'; + import {useNostrContext} from '../../../context/NostrContext'; -import {NDKEvent, NDKKind} from '@nostr-dev-kit/ndk'; -import { useAuth } from '../../../store'; +import {useAuth} from '../../../store'; +import {AdminGroupPermission} from './useAddPermissions'; +import {checkGroupPermission} from './useGetPermission'; -// TODO export const useAddMember = () => { const {ndk} = useNostrContext(); - const {publicKey} = useAuth(); + const {publicKey: pubkey} = useAuth(); return useMutation({ mutationKey: ['addMemberGroup', ndk], - mutationFn: async (data: {pubkey: string; groupId: string}) => { + mutationFn: async (data: { + pubkey: string; + groupId: string; + permissionData?: AdminGroupPermission[]; + }) => { const event = new NDKEvent(ndk); + const hasPermission = await checkGroupPermission({ + groupId: data.groupId, + ndk, + pubkey, + action: AdminGroupPermission.AddMember, + }); + if (!hasPermission) { + throw new Error('You do not have permission to add a member to this group'); + } event.kind = NDKKind.GroupAdminAddUser; event.tags = [ ['d', data.groupId], diff --git a/packages/afk_nostr_sdk/src/hooks/group/private/useAddPermissions.ts b/packages/afk_nostr_sdk/src/hooks/group/private/useAddPermissions.ts index 02a59045..4a41d372 100644 --- a/packages/afk_nostr_sdk/src/hooks/group/private/useAddPermissions.ts +++ b/packages/afk_nostr_sdk/src/hooks/group/private/useAddPermissions.ts @@ -1,6 +1,7 @@ -import {useMutation, useQuery} from '@tanstack/react-query'; +import {NDKEvent} from '@nostr-dev-kit/ndk'; +import {useMutation} from '@tanstack/react-query'; + import {useNostrContext} from '../../../context/NostrContext'; -import {NDKEvent, NDKKind} from '@nostr-dev-kit/ndk'; export enum AdminGroupPermission { AddMember = 'add-user', @@ -11,7 +12,9 @@ export enum AdminGroupPermission { RemovePermission = 'remove-permission', EditGroupStatus = 'edit-group-status', DeleteGroup = 'delete-group', + ViewAccess = 'view-access', } +type IAdminGroupPermission = `${AdminGroupPermission}`; export const useAddPermissions = () => { const {ndk} = useNostrContext(); @@ -20,14 +23,14 @@ export const useAddPermissions = () => { mutationKey: ['addPermissions', ndk], mutationFn: async (data: { pubkey: string; - permissionName: AdminGroupPermission[]; + permissionName: IAdminGroupPermission[]; groupId: string; }) => { const event = new NDKEvent(ndk); - event.kind = 9003 // NDKKind.GroupAdminAddPermission; + event.kind = 9003; // NDKKind.GroupAdminAddPermission; + event.tags = [ ['h', data.groupId], - ['d', data.groupId], ['p', data.pubkey, ...data.permissionName], ]; return event.publish(); diff --git a/packages/afk_nostr_sdk/src/hooks/group/private/useCreateGroup.ts b/packages/afk_nostr_sdk/src/hooks/group/private/useCreateGroup.ts index c5b5ec05..55ee5ea0 100644 --- a/packages/afk_nostr_sdk/src/hooks/group/private/useCreateGroup.ts +++ b/packages/afk_nostr_sdk/src/hooks/group/private/useCreateGroup.ts @@ -1,18 +1,37 @@ +import {NDKEvent, NDKKind} from '@nostr-dev-kit/ndk'; import {useMutation} from '@tanstack/react-query'; + import {useNostrContext} from '../../../context/NostrContext'; -import {NDKEvent, NDKKind} from '@nostr-dev-kit/ndk'; +import {useAuth} from '../../../store'; +/** + * Access means if a group is PRIVATE OR PUBLIC + * View means if a group is OPEN OR CLOSE. + */ +enum GroupEnum { + GROUP_ACCESS = 'access', + GROUP_VIEW = 'view', +} // TODO export const useCreateGroup = () => { const {ndk} = useNostrContext(); + const {publicKey} = useAuth(); return useMutation({ mutationKey: ['createGroup', ndk], - mutationFn: async (data?: {groupType: 'private' | 'public'}) => { + mutationFn: async (data?: {groupType: 'private' | 'public'; groupName: string}) => { const event = new NDKEvent(ndk); event.kind = NDKKind.GroupAdminCreateGroup; - event.tags = [[data.groupType || 'private']]; - return event.publish(); + event.content = data.groupName; + event.tags = [ + [GroupEnum.GROUP_ACCESS, data.groupType], + [GroupEnum.GROUP_VIEW, 'open'], + ['name', data.groupName], + ['p', publicKey], + ]; + + event.publish(); + return event; }, }); }; diff --git a/packages/afk_nostr_sdk/src/hooks/group/private/useDeleteEvent.ts b/packages/afk_nostr_sdk/src/hooks/group/private/useDeleteEvent.ts index 00917512..52a352d0 100644 --- a/packages/afk_nostr_sdk/src/hooks/group/private/useDeleteEvent.ts +++ b/packages/afk_nostr_sdk/src/hooks/group/private/useDeleteEvent.ts @@ -1,18 +1,24 @@ +import {NDKEvent} from '@nostr-dev-kit/ndk'; import {useMutation} from '@tanstack/react-query'; + import {useNostrContext} from '../../../context/NostrContext'; -import {NDKEvent, NDKKind} from '@nostr-dev-kit/ndk'; import {useAuth} from '../../../store'; -import {checkGroupPermission} from './useGetPermission'; import {AdminGroupPermission} from './useAddPermissions'; +import {checkGroupPermission} from './useGetPermission'; // TODO export const useDeleteEvent = () => { const {ndk} = useNostrContext(); const {publicKey: pubkey} = useAuth(); return useMutation({ - mutationKey: ['deleteEventGroup', ndk], - mutationFn: async (data: {id: string; groupId: string}) => { - const hasPermission = checkGroupPermission({ + mutationKey: ['deleteEventGroup'], + mutationFn: async (data: { + id: string; + groupId: string; + permissionData?: AdminGroupPermission[]; + }) => { + const event = new NDKEvent(ndk); + const hasPermission = await checkGroupPermission({ groupId: data.groupId, ndk, pubkey, @@ -22,8 +28,7 @@ export const useDeleteEvent = () => { if (!hasPermission) { throw new Error('You do not have permission to delete this event'); } - const event = new NDKEvent(ndk); - event.kind = 9005 //NDKKind.GroupAdminDeleteEvent; + event.kind = 9005; //NDKKind.GroupAdminDeleteEvent; event.tags = [ ['d', data.groupId], ['e', data.id], diff --git a/packages/afk_nostr_sdk/src/hooks/group/private/useDeleteGroup.ts b/packages/afk_nostr_sdk/src/hooks/group/private/useDeleteGroup.ts index 09dcc7da..a935a539 100644 --- a/packages/afk_nostr_sdk/src/hooks/group/private/useDeleteGroup.ts +++ b/packages/afk_nostr_sdk/src/hooks/group/private/useDeleteGroup.ts @@ -1,9 +1,10 @@ +import {NDKEvent} from '@nostr-dev-kit/ndk'; import {useMutation} from '@tanstack/react-query'; + import {useNostrContext} from '../../../context/NostrContext'; -import {NDKEvent, NDKKind} from '@nostr-dev-kit/ndk'; -import {checkGroupPermission} from './useGetPermission'; import {useAuth} from '../../../store'; import {AdminGroupPermission} from './useAddPermissions'; +import {checkGroupPermission} from './useGetPermission'; // TODO export const useDeleteGroup = () => { @@ -11,9 +12,10 @@ export const useDeleteGroup = () => { const {publicKey: pubkey} = useAuth(); return useMutation({ - mutationKey: ['deleteGroup', ndk], + mutationKey: ['deleteGroup'], mutationFn: async (data: {groupId: string}) => { - const hasPermission = checkGroupPermission({ + const event = new NDKEvent(ndk); + const hasPermission = await checkGroupPermission({ groupId: data.groupId, ndk, pubkey, @@ -23,8 +25,8 @@ export const useDeleteGroup = () => { if (!hasPermission) { throw new Error('You do not have permission to delete group'); } - const event = new NDKEvent(ndk); - event.kind = 9008 // NDKKind.GroupAdminDeleteGroup; + + event.kind = 9008; // NDKKind.GroupAdminDeleteGroup; event.tags = [['d', data.groupId]]; return event.publish(); }, diff --git a/packages/afk_nostr_sdk/src/hooks/group/private/useEditGroupStatus.ts b/packages/afk_nostr_sdk/src/hooks/group/private/useEditGroupStatus.ts index 17e9e496..5e511f35 100644 --- a/packages/afk_nostr_sdk/src/hooks/group/private/useEditGroupStatus.ts +++ b/packages/afk_nostr_sdk/src/hooks/group/private/useEditGroupStatus.ts @@ -1,10 +1,11 @@ +import {NDKEvent, NDKKind} from '@nostr-dev-kit/ndk'; import {useMutation} from '@tanstack/react-query'; + import {useNostrContext} from '../../../context/NostrContext'; -import {NDKEvent, NDKKind} from '@nostr-dev-kit/ndk'; -import {objectToTagArray} from './util'; import {useAuth} from '../../../store'; -import {checkGroupPermission} from './useGetPermission'; import {AdminGroupPermission} from './useAddPermissions'; +import {checkGroupPermission} from './useGetPermission'; +import {objectToTagArray} from './util'; type GroupStatus = { groupVisibility: 'public' | 'private'; @@ -17,9 +18,10 @@ export const useGroupEditStatus = () => { const {publicKey: pubkey} = useAuth(); return useMutation({ - mutationKey: ['editGroupStatus', ndk], + mutationKey: ['editGroupStatus'], mutationFn: async (data: {groupId: string; status: GroupStatus}) => { - const hasPermission = checkGroupPermission({ + const event = new NDKEvent(ndk); + const hasPermission = await checkGroupPermission({ groupId: data.groupId, ndk, pubkey, @@ -29,7 +31,6 @@ export const useGroupEditStatus = () => { if (!hasPermission) { throw new Error('You do not have permission to edit status'); } - const event = new NDKEvent(ndk); event.kind = NDKKind.GroupAdminEditStatus; event.tags = [['d', data.groupId], objectToTagArray(data.status)[0]]; return event.publish(); diff --git a/packages/afk_nostr_sdk/src/hooks/group/private/useGetGroupMember.ts b/packages/afk_nostr_sdk/src/hooks/group/private/useGetGroupMember.ts new file mode 100644 index 00000000..94539f78 --- /dev/null +++ b/packages/afk_nostr_sdk/src/hooks/group/private/useGetGroupMember.ts @@ -0,0 +1,170 @@ +import {NDKEvent, NDKKind} from '@nostr-dev-kit/ndk'; +import {useInfiniteQuery} from '@tanstack/react-query'; + +import {useNostrContext} from '../../../context/NostrContext'; +import {useAuth} from '../../../store'; + +interface UseGetGroupListOptions { + limit?: number; + search?: string; + groupId: string; +} + +interface UseGetGroupListOptions { + limit?: number; + search?: string; + groupId: string; +} + +export const useGetGroupMemberList = (options: UseGetGroupListOptions) => { + const {ndk} = useNostrContext(); + + return useInfiniteQuery({ + queryKey: ['getAllGroupMember', options.search, options.groupId], + initialPageParam: 0, + getNextPageParam: (lastPage, allPages, lastPageParam) => { + if (!lastPage?.length) return undefined; + const pageParam = lastPage[lastPage.length - 1].created_at - 1; + if (!pageParam || pageParam === lastPageParam) return undefined; + return pageParam; + }, + queryFn: async ({pageParam}) => { + const events = await ndk.fetchEvents({ + kinds: [NDKKind.GroupAdminAddUser, NDKKind.GroupAdminRemoveUser], + '#d': [options.groupId], + until: pageParam || Math.round(Date.now() / 1000), + limit: options?.limit || 20, + search: options?.search, + }); + + const memberMap = new Map(); + + [...events] + .sort((a, b) => a.created_at - b.created_at) + .forEach((event) => { + const pubkey = event.tags.find((tag) => tag[0] === 'p')?.[1]; + if (!pubkey) return; + + if (event.kind === NDKKind.GroupAdminAddUser) { + memberMap.set(pubkey, {...event, isRemoved: false}); + } else if (event.kind === NDKKind.GroupAdminRemoveUser) { + const existingMember = memberMap.get(pubkey); + if (existingMember) { + memberMap.set(pubkey, {...existingMember, isRemoved: true}); + } + } + }); + + const currentMembers = Array.from(memberMap.values()) + .filter((member) => !member.isRemoved) + .sort((a, b) => b.created_at - a.created_at); + + return currentMembers; + }, + placeholderData: {pages: [], pageParams: []}, + }); +}; + +export const useGetGroupRequest = (options: UseGetGroupListOptions) => { + const {ndk} = useNostrContext(); + const memberListQuery = useGetGroupMemberList(options); + return useInfiniteQuery({ + queryKey: ['getGroupRequest', options.search, options.groupId], + initialPageParam: 0, + getNextPageParam: (lastPage, allPages, lastPageParam) => { + if (!lastPage?.length) return undefined; + const pageParam = lastPage[lastPage.length - 1].created_at - 1; + if (!pageParam || pageParam === lastPageParam) return undefined; + return pageParam; + }, + queryFn: async ({pageParam}) => { + const events = await ndk.fetchEvents({ + kinds: [NDKKind.GroupAdminRequestJoin], + '#h': [options.groupId], + until: pageParam || Math.round(Date.now() / 1000), + limit: options?.limit || 20, + }); + // Wait for the member list to be available + await memberListQuery.refetch(); + + const memberPubkeys = new Set( + memberListQuery.data?.pages + .flatMap((page) => page.map((member) => member.tags.find((tag) => tag[0] === 'p')?.[1])) + .filter(Boolean), + ); + + // Use a Map to keep track of the latest request from each user + const latestRequests = new Map(); + + [...events].forEach((event) => { + const requestPubkey = event.tags.find((tag) => tag[0] === 'p')?.[1]; + if (requestPubkey && !memberPubkeys.has(requestPubkey)) { + const existingRequest = latestRequests.get(requestPubkey); + if (!existingRequest || event.created_at > existingRequest.created_at) { + latestRequests.set(requestPubkey, event); + } + } + }); + + // Convert the Map values to an array and sort by creation time (newest first) + const uniqueFilteredEvents = Array.from(latestRequests.values()).sort( + (a, b) => b.created_at - a.created_at, + ); + + return uniqueFilteredEvents; + }, + placeholderData: {pages: [], pageParams: []}, + }); +}; + +export const useGetGroupMemberListPubkey = (options: UseGetGroupListOptions) => { + const {ndk} = useNostrContext(); + const {publicKey} = useAuth(); + + return useInfiniteQuery({ + queryKey: ['getAllGroupMember', publicKey, options.search, options.groupId], + initialPageParam: 0, + getNextPageParam: (lastPage, allPages, lastPageParam) => { + if (!lastPage?.length) return undefined; + const pageParam = lastPage[lastPage.length - 1].created_at - 1; + if (!pageParam || pageParam === lastPageParam) return undefined; + return pageParam; + }, + queryFn: async ({pageParam}) => { + const events = await ndk.fetchEvents({ + kinds: [NDKKind.GroupAdminAddUser, NDKKind.GroupAdminRemoveUser], + authors: [publicKey], + '#d': [options.groupId], + until: pageParam || Math.round(Date.now() / 1000), + limit: options?.limit || 20, + search: options?.search, + }); + + const memberMap = new Map(); + + [...events] + .sort((a, b) => a.created_at - b.created_at) + .forEach((event) => { + const pubkey = event.tags.find((tag) => tag[0] === 'p')?.[1]; + if (!pubkey) return; + + if (event.kind === NDKKind.GroupAdminAddUser) { + memberMap.set(pubkey, {...event, isRemoved: false}); + } else if (event.kind === NDKKind.GroupAdminRemoveUser) { + const existingMember = memberMap.get(pubkey); + if (existingMember) { + // memberMap.set(pubkey, {...existingMember, isRemoved: true}); + memberMap.delete(pubkey); + } + } + }); + + const currentMembers = Array.from(memberMap.values()) + .filter((member) => !member.isRemoved) + .sort((a, b) => b.created_at - a.created_at); + + return currentMembers; + }, + placeholderData: {pages: [], pageParams: []}, + }); +}; diff --git a/packages/afk_nostr_sdk/src/hooks/group/private/useGetGroupMessage.ts b/packages/afk_nostr_sdk/src/hooks/group/private/useGetGroupMessage.ts new file mode 100644 index 00000000..38bed899 --- /dev/null +++ b/packages/afk_nostr_sdk/src/hooks/group/private/useGetGroupMessage.ts @@ -0,0 +1,73 @@ +import {NDKKind} from '@nostr-dev-kit/ndk'; +import {useInfiniteQuery} from '@tanstack/react-query'; + +import {useNostrContext} from '../../../context/NostrContext'; + +interface UseGetActiveGroupListOptions { + search?: string; + limit?: number; + groupId: string; + authors: string; + content?: string; +} + +export const useGetGroupMessages = (options: UseGetActiveGroupListOptions) => { + const {ndk} = useNostrContext(); + + return useInfiniteQuery({ + queryKey: ['getGroupMessages', options.groupId, options?.search], + initialPageParam: 0, + getNextPageParam: (lastPage, allPages, lastPageParam) => { + if (!lastPage?.length) return undefined; + + const pageParam = lastPage[lastPage.length - 1].created_at - 1; + if (!pageParam || pageParam === lastPageParam) return undefined; + return pageParam; + }, + queryFn: async ({pageParam}) => { + const events = await ndk.fetchEvents({ + '#h': [options.groupId], + kinds: [NDKKind.GroupNote, NDKKind.GroupReply], + // authors: [options?.authors], + search: options?.search, + until: pageParam || Math.round(Date.now() / 1000), + limit: options?.limit || 100, + }); + + const eventMap = new Map(); + const replyMap = new Map(); + + // Single pass: Store all events and process replies + events.forEach((event) => { + eventMap.set(event.id, event); + + const replyTag = event.tags.find((tag) => tag[0] === 'e' && tag[3] === 'reply'); + if (replyTag) { + const rootId = replyTag[1]; + const rootMessage = eventMap.get(rootId); + if (rootMessage) { + event['reply'] = rootMessage; + } else { + if (!replyMap.has(rootId)) { + replyMap.set(rootId, []); + } + replyMap.get(rootId).push(event); + } + } + }); + + // Process any remaining replies + replyMap.forEach((replies, rootId) => { + const rootMessage = eventMap.get(rootId); + if (rootMessage) { + replies.forEach((reply) => { + reply['reply'] = rootMessage; + }); + } + }); + + return [...eventMap.values()]; + }, + placeholderData: {pages: [], pageParams: []}, + }); +}; diff --git a/packages/afk_nostr_sdk/src/hooks/group/private/useGetGroups.ts b/packages/afk_nostr_sdk/src/hooks/group/private/useGetGroups.ts new file mode 100644 index 00000000..8db62d0e --- /dev/null +++ b/packages/afk_nostr_sdk/src/hooks/group/private/useGetGroups.ts @@ -0,0 +1,136 @@ +import {NDKEvent, NDKKind} from '@nostr-dev-kit/ndk'; +import {useInfiniteQuery} from '@tanstack/react-query'; + +import {useNostrContext} from '../../../context/NostrContext'; + +interface UseGetActiveGroupListOptions { + pubKey?: string | undefined; + search?: string; + limit?: number; +} + +/** + * Use this hooks to get all Private Group List that has not been deleted + * This Hooks uses originalId key to keep track of each group because a group can have multiple edits + * @param options + * @returns + */ +export const useGetGroupList = (options: UseGetActiveGroupListOptions) => { + const {ndk} = useNostrContext(); + const GroupAdminDeleteGroup: any = 9008; + const groupMap = new Map(); + + return useInfiniteQuery({ + queryKey: ['getAllGroups', options.pubKey, options?.search], + initialPageParam: 0, + getNextPageParam: (lastPage, allPages, lastPageParam) => { + if (!lastPage?.length) return undefined; + + const pageParam = lastPage[lastPage.length - 1].created_at - 1; + if (!pageParam || pageParam === lastPageParam) return undefined; + return pageParam; + }, + queryFn: async ({pageParam}) => { + const events = await ndk.fetchEvents({ + kinds: [ + NDKKind.GroupAdminCreateGroup, + GroupAdminDeleteGroup, + NDKKind.GroupAdminEditMetadata, + ], + // authors: options?.pubKey ? [options.pubKey]: [], + until: pageParam || Math.round(Date.now() / 1000), + }); + [...events] + .sort((a, b) => a.created_at - b.created_at) + .forEach((event) => { + let groupId: string; + + if (event.kind === NDKKind.GroupAdminCreateGroup) { + groupId = event.id; + groupMap.set(groupId, {event, originalGroupId: groupId}); + } else if (event.kind === NDKKind.GroupAdminEditMetadata) { + groupId = event.tags.find((tag) => tag[0] === 'd')?.[1]; + if (groupId && groupMap.has(groupId)) { + const originalGroupId = groupMap.get(groupId)!.originalGroupId; + groupMap.set(groupId, {event, originalGroupId}); + } + } else if (event.kind === GroupAdminDeleteGroup) { + groupId = event.tags.find((tag) => tag[0] === 'd')?.[1]; + if (groupId) { + groupMap.delete(groupId); + } + } + }); + + const activeGroups = Array.from(groupMap.values()) + .map(({event, originalGroupId}) => ({ + ...event, + originalGroupId, + })) + .sort((a, b) => b.created_at - a.created_at); + + return activeGroups; + }, + placeholderData: {pages: [], pageParams: []}, + }); +}; + +/** + * This hooks returns all groups added by admin. + * @param options + * @returns + */ +export const useGetAllGroupList = (options: UseGetActiveGroupListOptions) => { + const {ndk} = useNostrContext(); + + return useInfiniteQuery({ + queryKey: ['getAllGroupLists', options.pubKey, options?.search], + initialPageParam: 0, + getNextPageParam: (lastPage, allPages, lastPageParam) => { + if (!lastPage?.length) return undefined; + + const pageParam = lastPage[lastPage.length - 1].created_at - 1; + if (!pageParam || pageParam === lastPageParam) return undefined; + return pageParam; + }, + queryFn: async ({pageParam}) => { + const events = await ndk.fetchEvents({ + kinds: [NDKKind.GroupAdminAddUser], + until: pageParam || Math.round(Date.now() / 1000), + }); + + return [...events]; + }, + placeholderData: {pages: [], pageParams: []}, + }); +}; + +/** + * This hooks returns all groups added by admin. + * @param options + * @returns + */ +export const useGetAllGroupListByMemberAdded = (options: UseGetActiveGroupListOptions) => { + const {ndk} = useNostrContext(); + + return useInfiniteQuery({ + queryKey: ['getAllGroupLists', options.pubKey, options?.search], + initialPageParam: 0, + getNextPageParam: (lastPage, allPages, lastPageParam) => { + if (!lastPage?.length) return undefined; + + const pageParam = lastPage[lastPage.length - 1].created_at - 1; + if (!pageParam || pageParam === lastPageParam) return undefined; + return pageParam; + }, + queryFn: async ({pageParam}) => { + const events = await ndk.fetchEvents({ + kinds: [NDKKind.GroupAdminAddUser], + until: pageParam || Math.round(Date.now() / 1000), + }); + + return [...events]; + }, + placeholderData: {pages: [], pageParams: []}, + }); +}; diff --git a/packages/afk_nostr_sdk/src/hooks/group/private/useGetPermission.ts b/packages/afk_nostr_sdk/src/hooks/group/private/useGetPermission.ts index a19a6764..515d55fa 100644 --- a/packages/afk_nostr_sdk/src/hooks/group/private/useGetPermission.ts +++ b/packages/afk_nostr_sdk/src/hooks/group/private/useGetPermission.ts @@ -1,6 +1,7 @@ +import NDK, {NDKEvent} from '@nostr-dev-kit/ndk'; import {useQuery} from '@tanstack/react-query'; + import {useNostrContext} from '../../../context/NostrContext'; -import NDK, {NDKEvent, NDKKind} from '@nostr-dev-kit/ndk'; import {useAuth} from '../../../store'; import {AdminGroupPermission} from './useAddPermissions'; @@ -11,39 +12,39 @@ type PermissionMeta = { pubkey: string; groupId: string; }; -type CheckPermissionMeta = PermissionMeta & {action: Permission}; -export const useGetPermissionsByUserConnected = (groupId: string) => { - const {ndk} = useNostrContext(); - const {publicKey} = useAuth(); - return useQuery({ - queryKey: ['getPermissionsByUserConnected', groupId], - queryFn: () => - fetchPermissions({ - ndk, - groupId, - pubkey: publicKey, - }), - }); -}; +type CheckPermissionMeta = PermissionMeta & {action: Permission}; -// Function for fetching permissions -const fetchPermissions = async ({ +/** + * Util Fetch for hook permission check + * @param param0 + * @returns + */ +export const fetchPermissions = async ({ ndk, groupId, pubkey, }: PermissionMeta): Promise => { - const events = await ndk.fetchEvent({ - kinds: [NDKKind.GroupAdmins], - '#d': [groupId], + const events = await ndk.fetchEvents({ + kinds: [9003], + '#h': [groupId], '#p': [pubkey], - limit: 1, + since: 0, }); - return events ?? null; + if (!events || events.size === 0) return null; + + const sortedEvents = [...events].sort((a, b) => b.created_at - a.created_at); + + // Return the latest event + return sortedEvents[0]; }; -// Function for checking permissions +/** + * Util Fetch for hook permission check + * @param param0 + * @returns + */ export const checkGroupPermission = async ({ndk, groupId, pubkey, action}: CheckPermissionMeta) => { const event = await fetchPermissions({ ndk, @@ -62,9 +63,48 @@ export const checkGroupPermission = async ({ndk, groupId, pubkey, action}: Check return false; // User not found in the admin event } - // Get the user's permissions (all elements after the label) - const userPermissions = userTag.length > 3 ? userTag.slice(3) : []; + // Get the user's permissions (all elements after the pubkey) + const userPermissions = userTag.slice(2); // Check if the user has the required permission return userPermissions.includes(action); }; + +/* *********************** */ +/** + * UI side Util Hook + * @param groupId + * @returns + */ +export const useGetGroupPermission = (groupId: string) => { + const {ndk} = useNostrContext(); + const {publicKey} = useAuth(); + + return useQuery({ + queryKey: ['getPermissionsByUserConnected', groupId], + enabled: !!groupId, + queryFn: async () => { + const events = await ndk.fetchEvents({ + kinds: [9003], + '#h': [groupId], + '#p': [publicKey], + }); + + if (!events || events.size === 0) return []; + + // Sort events by creation time (descending) to get the latest + const sortedEvents = [...events].sort((a, b) => b.created_at - a.created_at); + + // Get the latest event + const latestEvent = sortedEvents[0]; + + // Find the tag for this specific user + const userTag = latestEvent.tags.find((tag) => tag[0] === 'p' && tag[1] === publicKey); + + if (!userTag) return []; + + // Return the user's permissions (all elements after the pubkey) + return userTag.slice(2); + }, + }); +}; diff --git a/packages/afk_nostr_sdk/src/hooks/group/private/useGroupEditMetadata.ts b/packages/afk_nostr_sdk/src/hooks/group/private/useGroupEditMetadata.ts index 316747a5..107aa2e2 100644 --- a/packages/afk_nostr_sdk/src/hooks/group/private/useGroupEditMetadata.ts +++ b/packages/afk_nostr_sdk/src/hooks/group/private/useGroupEditMetadata.ts @@ -1,25 +1,38 @@ -import {useMutation} from '@tanstack/react-query'; -import {useNostrContext} from '../../../context/NostrContext'; import {NDKEvent, NDKKind} from '@nostr-dev-kit/ndk'; -import {objectToTagArray} from './util'; +import {useMutation, useQuery} from '@tanstack/react-query'; + +import {useNostrContext} from '../../../context/NostrContext'; import {useAuth} from '../../../store'; -import {checkGroupPermission} from './useGetPermission'; import {AdminGroupPermission} from './useAddPermissions'; +import {checkGroupPermission} from './useGetPermission'; +import {objectToTagArray} from './util'; type UpdateMetaData = { name?: string; about?: string; + access?: string; picture?: string; }; -// TODO +interface UseGetGroupMetaData { + pubKey: string; + search?: string; + limit?: number; + groupId: string; +} + export const useGroupEditMetadata = () => { const {ndk} = useNostrContext(); const {publicKey: pubkey} = useAuth(); return useMutation({ mutationKey: ['editGroupMetadata', ndk], - mutationFn: async (data: {groupId: string; meta: UpdateMetaData}) => { + mutationFn: async (data: { + groupId: string; + meta: UpdateMetaData; + permissionData?: AdminGroupPermission[]; + }) => { + const event = new NDKEvent(ndk); const hasPermission = checkGroupPermission({ groupId: data.groupId, ndk, @@ -30,11 +43,30 @@ export const useGroupEditMetadata = () => { if (!hasPermission) { throw new Error('You do not have permission to edit metadata'); } - const event = new NDKEvent(ndk); + const editedTag = objectToTagArray(data.meta); + + event.content = data.meta.name; event.kind = NDKKind.GroupAdminEditMetadata; - event.tags = [['d', data.groupId], objectToTagArray(data.meta)[0]]; + event.tags = [['h', data.groupId], ['d', data.groupId], ...editedTag]; return event.publish(); }, }); }; + +export const useGetGroupMetadata = (options: UseGetGroupMetaData) => { + const {ndk} = useNostrContext(); + + return useQuery({ + queryKey: ['getGroupMetaData', options.pubKey, options.groupId], + queryFn: async () => { + const events = await ndk.fetchEvent({ + kinds: [NDKKind.GroupAdminEditMetadata], + authors: [options.pubKey], + '#d': [options.groupId], + }); + + return events ?? null; + }, + }); +}; diff --git a/packages/afk_nostr_sdk/src/hooks/group/private/useJoinRequest.ts b/packages/afk_nostr_sdk/src/hooks/group/private/useJoinRequest.ts index 0f9cc885..4f17e5c9 100644 --- a/packages/afk_nostr_sdk/src/hooks/group/private/useJoinRequest.ts +++ b/packages/afk_nostr_sdk/src/hooks/group/private/useJoinRequest.ts @@ -1,9 +1,12 @@ +import {NDKEvent, NDKKind} from '@nostr-dev-kit/ndk'; import {useMutation} from '@tanstack/react-query'; + import {useNostrContext} from '../../../context/NostrContext'; -import {NDKEvent, NDKKind} from '@nostr-dev-kit/ndk'; +import {useAuth} from '../../../store'; export const useJoinGroupRequest = () => { const {ndk} = useNostrContext(); + const {publicKey} = useAuth(); return useMutation({ mutationKey: ['joinGroupRequest', ndk], @@ -11,7 +14,10 @@ export const useJoinGroupRequest = () => { const event = new NDKEvent(ndk); event.kind = NDKKind.GroupAdminRequestJoin; event.content = data?.content || ''; - event.tags = [['h', data.groupId]]; + event.tags = [ + ['h', data.groupId], + ['p', publicKey], + ]; return event.publish(); }, }); diff --git a/packages/afk_nostr_sdk/src/hooks/group/private/useLeaveRequest.ts b/packages/afk_nostr_sdk/src/hooks/group/private/useLeaveRequest.ts index d6caa5c7..70ee8966 100644 --- a/packages/afk_nostr_sdk/src/hooks/group/private/useLeaveRequest.ts +++ b/packages/afk_nostr_sdk/src/hooks/group/private/useLeaveRequest.ts @@ -1,6 +1,7 @@ +import {NDKEvent} from '@nostr-dev-kit/ndk'; import {useMutation} from '@tanstack/react-query'; + import {useNostrContext} from '../../../context/NostrContext'; -import {NDKEvent, NDKKind} from '@nostr-dev-kit/ndk'; // TODO export const useLeaveGroupRequest = () => { @@ -10,7 +11,7 @@ export const useLeaveGroupRequest = () => { mutationKey: ['leaveGroupRequest', ndk], mutationFn: async (data: {groupId: string; content?: string}) => { const event = new NDKEvent(ndk); - event.kind = 9022 // NDKKind.GroupAdminRequestLeave; + event.kind = 9022; // NDKKind.GroupAdminRequestLeave; event.content = data?.content || ''; event.tags = [['h', data.groupId]]; return event.publish(); diff --git a/packages/afk_nostr_sdk/src/hooks/group/private/useRemoveMember.ts b/packages/afk_nostr_sdk/src/hooks/group/private/useRemoveMember.ts index b454524f..4eac7dee 100644 --- a/packages/afk_nostr_sdk/src/hooks/group/private/useRemoveMember.ts +++ b/packages/afk_nostr_sdk/src/hooks/group/private/useRemoveMember.ts @@ -1,19 +1,20 @@ +import {NDKEvent, NDKKind} from '@nostr-dev-kit/ndk'; import {useMutation} from '@tanstack/react-query'; + import {useNostrContext} from '../../../context/NostrContext'; -import {NDKEvent, NDKKind} from '@nostr-dev-kit/ndk'; import {useAuth} from '../../../store'; -import {checkGroupPermission} from './useGetPermission'; import {AdminGroupPermission} from './useAddPermissions'; +import {checkGroupPermission} from './useGetPermission'; -// TODO export const useRemoveMember = () => { const {ndk} = useNostrContext(); const {publicKey: pubkey} = useAuth(); return useMutation({ - mutationKey: ['removeMemberGroup', ndk], + mutationKey: ['removeMemberGroup'], mutationFn: async (data: {pubkey: string; groupId: string}) => { - const hasPermission = checkGroupPermission({ + const event = new NDKEvent(ndk); + const hasPermission = await checkGroupPermission({ groupId: data.groupId, ndk, pubkey, @@ -23,13 +24,15 @@ export const useRemoveMember = () => { if (!hasPermission) { throw new Error('You do not have permission to remove member'); } - const event = new NDKEvent(ndk); event.kind = NDKKind.GroupAdminRemoveUser; event.tags = [ + ['h', data.groupId], ['d', data.groupId], ['p', data.pubkey], ]; - return event.publish(); + + event.publish(); + return event; }, }); }; diff --git a/packages/afk_nostr_sdk/src/hooks/group/private/useRemovePermissions.ts b/packages/afk_nostr_sdk/src/hooks/group/private/useRemovePermissions.ts index 6c4e0c0b..058b60dc 100644 --- a/packages/afk_nostr_sdk/src/hooks/group/private/useRemovePermissions.ts +++ b/packages/afk_nostr_sdk/src/hooks/group/private/useRemovePermissions.ts @@ -1,23 +1,35 @@ +import {NDKEvent} from '@nostr-dev-kit/ndk'; import {useMutation} from '@tanstack/react-query'; + import {useNostrContext} from '../../../context/NostrContext'; -import {NDKEvent, NDKKind} from '@nostr-dev-kit/ndk'; +import {useAuth} from '../../../store'; import {AdminGroupPermission} from './useAddPermissions'; -import { useAuth } from '../../../store'; +import {checkGroupPermission} from './useGetPermission'; // TODO export const useRemovePermissions = () => { const {ndk} = useNostrContext(); - const {publicKey} = useAuth(); + const {publicKey: pubkey} = useAuth(); return useMutation({ - mutationKey: ['removePermissions', ndk], + mutationKey: ['removePermissions'], mutationFn: async (data: { pubkey: string; permissionName: AdminGroupPermission[]; groupId: string; }) => { const event = new NDKEvent(ndk); - event.kind = 9004 // NDKKind.GroupAdminRemovePermission; + const hasPermission = await checkGroupPermission({ + groupId: data.groupId, + ndk, + pubkey, + action: AdminGroupPermission.DeleteGroup, + }); + + if (!hasPermission) { + throw new Error('You do not have access to remove permission'); + } + event.kind = 9004; // NDKKind.GroupAdminRemovePermission; event.tags = [ ['d', data.groupId], diff --git a/packages/afk_nostr_sdk/src/hooks/group/private/useSendGroupMessage.ts b/packages/afk_nostr_sdk/src/hooks/group/private/useSendGroupMessage.ts index 576e457a..87252048 100644 --- a/packages/afk_nostr_sdk/src/hooks/group/private/useSendGroupMessage.ts +++ b/packages/afk_nostr_sdk/src/hooks/group/private/useSendGroupMessage.ts @@ -1,26 +1,53 @@ +import {NDKEvent, NDKKind} from '@nostr-dev-kit/ndk'; import {useMutation} from '@tanstack/react-query'; + import {useNostrContext} from '../../../context/NostrContext'; -import {NDKEvent, NDKKind} from '@nostr-dev-kit/ndk'; import {useAuth} from '../../../store'; +import {AdminGroupPermission} from './useAddPermissions'; +import {checkGroupPermission} from './useGetPermission'; // TODO export const useSendGroupMessages = () => { const {ndk} = useNostrContext(); - const {publicKey} = useAuth(); + const {publicKey: pubkey} = useAuth(); return useMutation({ - mutationKey: ['sendGroupMessage', ndk], + mutationKey: ['sendGroupMessage'], mutationFn: async (data: { pubkey: string; content: string; groupId: string; - tag?: string[]; + name?: string; + replyId: string; }) => { const event = new NDKEvent(ndk); - event.kind = NDKKind.GroupNote; + + const hasPermission = await checkGroupPermission({ + groupId: data.groupId, + ndk, + pubkey, + action: AdminGroupPermission.ViewAccess, + }); + + if (!hasPermission) { + throw new Error('You do not have permission to send message'); + } + event.content = data.content; + // Set the kind based on whether it's a reply or not + event.kind = data.replyId ? NDKKind.GroupReply : NDKKind.GroupNote; // Using literal kind values + + // Base tags + event.tags = [ + ['h', data.groupId], + ['p', data.pubkey], + ['name', data.name], + ]; - event.tags = [['h', data.groupId], data.tag]; + // Check if it's a reply and append NIP-10 markers + if (data.replyId) { + event.tags.push(['e', data.replyId, '', 'reply']); + } return event.publish(); }, }); diff --git a/packages/afk_nostr_sdk/src/hooks/index.ts b/packages/afk_nostr_sdk/src/hooks/index.ts index cdf4c4a7..37e6e870 100644 --- a/packages/afk_nostr_sdk/src/hooks/index.ts +++ b/packages/afk_nostr_sdk/src/hooks/index.ts @@ -20,13 +20,21 @@ export {useRootNotes} from './useRootNotes'; export {useSearchNotes} from './useSearchNotes'; export {useSendNote} from './useSendNote'; export {useAddMember} from './group/private/useAddMember'; -export {useAddPermissions} from './group/private/useAddPermissions'; +export { + useGetGroupMemberList, + useGetGroupRequest, + useGetGroupMemberListPubkey, +} from './group/private/useGetGroupMember'; +export {useGetGroupPermission} from './group/private/useGetPermission'; +export {useAddPermissions, AdminGroupPermission} from './group/private/useAddPermissions'; export {useCreateGroup} from './group/private/useCreateGroup'; +export {useGetGroupList, useGetAllGroupList} from './group/private/useGetGroups'; export {useDeleteEvent} from './group/private/useDeleteEvent'; -export {useGroupEditMetadata} from './group/private/useGroupEditMetadata'; +export {useGroupEditMetadata, useGetGroupMetadata} from './group/private/useGroupEditMetadata'; export {useRemovePermissions} from './group/private/useRemovePermissions'; export {useRemoveMember} from './group/private/useRemoveMember'; export {useSendGroupMessages} from './group/private/useSendGroupMessage'; +export {useGetGroupMessages} from './group/private/useGetGroupMessage'; export {useGroupEditStatus} from './group/private/useEditGroupStatus'; export {useDeleteGroup} from './group/private/useDeleteGroup'; export {useJoinGroupRequest} from './group/private/useJoinRequest'; @@ -36,4 +44,3 @@ export {useSendPrivateMessage} from './messages/useSendPrivateMessage'; export {useMyGiftWrapMessages} from './messages/useMyGiftWrapMessages'; export {useMyMessagesSent} from './messages/useMyMessagesSent'; export {useBookmark} from './useBookmark'; - diff --git a/packages/afk_nostr_sdk/src/hooks/search/useSearch.tsx b/packages/afk_nostr_sdk/src/hooks/search/useSearch.tsx index 9f92f371..ce25b714 100644 --- a/packages/afk_nostr_sdk/src/hooks/search/useSearch.tsx +++ b/packages/afk_nostr_sdk/src/hooks/search/useSearch.tsx @@ -10,6 +10,7 @@ export type UseSearch = { kind?: NDKKind; kinds?: NDKKind[]; sortBy?: string; + limit?:number; }; export const useSearch = (options?: UseSearch) => { @@ -34,7 +35,7 @@ export const useSearch = (options?: UseSearch) => { search: options?.search, // content: options?.search, until: pageParam || Math.round(Date.now() / 1000), - limit: 20, + limit: options?.limit ?? 20, }); console.log('notes', notes); diff --git a/packages/afk_nostr_sdk/src/hooks/useAllProfiles.ts b/packages/afk_nostr_sdk/src/hooks/useAllProfiles.ts index 835af5d4..82b49fc0 100644 --- a/packages/afk_nostr_sdk/src/hooks/useAllProfiles.ts +++ b/packages/afk_nostr_sdk/src/hooks/useAllProfiles.ts @@ -6,6 +6,7 @@ import {useNostrContext} from '../context/NostrContext'; export type UseRootProfilesOptions = { authors?: string[]; search?: string; + limit?:number; }; export const useAllProfiles = (options?: UseRootProfilesOptions) => { @@ -28,7 +29,7 @@ export const useAllProfiles = (options?: UseRootProfilesOptions) => { authors: options?.authors, search: options?.search, until: pageParam || Math.round(Date.now() / 1000), - limit: 20, + limit: options?.limit ?? 20, }); return [...notes].filter((note) => note.tags.every((tag) => tag[0] !== 'e')); diff --git a/packages/afk_nostr_sdk/src/hooks/useBookmark.ts b/packages/afk_nostr_sdk/src/hooks/useBookmark.ts index 36a37a36..9f541109 100644 --- a/packages/afk_nostr_sdk/src/hooks/useBookmark.ts +++ b/packages/afk_nostr_sdk/src/hooks/useBookmark.ts @@ -16,78 +16,161 @@ export const useBookmark = (userPublicKey: string) => { const { ndk } = useNostrContext(); const queryClient = useQueryClient(); + const fetchBookmarks = async () => { + if (!ndk.signer) { + throw new Error('No signer available'); + } + const filter = { kinds: [NDKKind.BookmarkList, NDKKind.BookmarkSet], authors: [userPublicKey] }; + const events = await ndk.fetchEvents(filter); + + const eventsArray = Array.from(events); + + // Fetch full content for each bookmarked event + const fullEvents = await Promise.all(eventsArray.map(async (event) => { + const fullEvent = await ndk.fetchEvent(event.id); + return fullEvent; + })); + + return fullEvents; + }; + + const bookmarks = useQuery({ + queryKey: ['bookmarks', userPublicKey], + queryFn: fetchBookmarks, + enabled: !!userPublicKey, + }); + + const extractNoteIds = (bookmarks: NDKEvent[]) => { + const noteIds: Set = new Set(); + + bookmarks.forEach(bookmark => { + bookmark.tags.forEach(tag => { + if (tag[0] === 'e') { + noteIds.add(tag[1]); // Collect note IDs + } + }); + }); + + return Array.from(noteIds); + }; + + const fetchNotesByIds = async (noteIds: string[]) => { + if (!ndk.signer) { + throw new Error('No signer available'); + } + + const filter = { ids: noteIds }; + const events = await ndk.fetchEvents(filter); + return Array.from(events); + }; + + const fetchBookmarksWithNotes = async () => { + const bookmarks = await fetchBookmarks(); + const noteIds = extractNoteIds(bookmarks); + const notes = await fetchNotesByIds(noteIds); + + // Create a mapping of note ID to note event + const noteMap = new Map(notes.map(note => [note.id, note])); + + // Combine bookmarks with note data + const bookmarksWithNotes = bookmarks.map(bookmark => { + const bookmarkedNotes = bookmark.tags + .filter(tag => tag[0] === 'e') + .map(tag => noteMap.get(tag[1])); + + return { + bookmarkEvent: bookmark, + notes: bookmarkedNotes, + }; + }); + + return bookmarksWithNotes; + }; + + const bookmarksWithNotesQuery = useQuery({ + queryKey: ['bookmarksWithNotes', userPublicKey], + queryFn: fetchBookmarksWithNotes, + enabled: !!userPublicKey, + }); + + const bookmarkNote = useMutation({ - mutationKey: ["bookmark", ndk], + mutationKey: ['bookmark', ndk], mutationFn: async ({ event, category }: BookmarkParams) => { if (!event) { throw new Error('No event provided for bookmark'); } - const bookmarkEvent = new NDKEvent(ndk); + let bookmarks = await fetchBookmarks(); + let bookmarkEvent = bookmarks.find((e) => e.kind === (category ? NDKKind.BookmarkSet : NDKKind.BookmarkList)); + + console.log('bookmarkEvent', bookmarkEvent); + + if (!bookmarkEvent) { + bookmarkEvent = new NDKEvent(ndk); + bookmarkEvent.kind = category ? NDKKind.BookmarkSet : NDKKind.BookmarkList; + bookmarkEvent.content = ''; + bookmarkEvent.tags = []; + } + + // Resetting the id and created_at to avoid conflicts + bookmarkEvent.id = undefined as any; + bookmarkEvent.created_at = undefined; + // If there's a specific category, add it if (category) { - bookmarkEvent.kind = NDKKind.BookmarkSet; - bookmarkEvent.tags = [ - ['d', category], - ['e', event.id, event.relay?.url || ''], - ['p', event.pubkey], - ]; - } else { - bookmarkEvent.kind = 10003; - bookmarkEvent.tags = [ - ['e', event.id, event.relay?.url || ''], - ['p', event.pubkey], - ]; + const existingTagIndex = bookmarkEvent.tags.findIndex(tag => tag[0] === 'd' && tag[1] === category); + if (existingTagIndex === -1) { + bookmarkEvent.tags.push(['d', category]); + } } - await bookmarkEvent.publish(); - return bookmarkEvent; + const existingEventIndex = bookmarkEvent.tags.findIndex(tag => tag[0] === 'e' && tag[1] === event.id); + if (existingEventIndex === -1) { + bookmarkEvent.tags.push(['e', event.id, event.relay?.url || '']); + bookmarkEvent.tags.push(['p', event.pubkey]); + } + + await bookmarkEvent.sign(); + return bookmarkEvent.publish(); + }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['bookmarks'] }); + queryClient.invalidateQueries({ queryKey: ['bookmarks', userPublicKey] }); }, onError: (error) => { console.error('Failed to bookmark note:', error); }, }); - const getBookmarks = useQuery({ - queryKey: ['bookmarks', userPublicKey], - queryFn: async () => { - if (!ndk.signer) { - throw new Error('No signer available'); - } - const filter = { kinds: [10003, 30003], authors: [userPublicKey] }; - const events = await ndk.fetchEvents(filter); - return Array.from(events); - }, - }); - const removeBookmark = useMutation({ - mutationKey: ["bookmark", ndk], + mutationKey: ['bookmarks', ndk], mutationFn: async ({ eventId, category }: RemoveBookmarkParams) => { - const existingBookmarks = getBookmarks.data; + let bookmarks = await fetchBookmarks(); + let bookmarkEvent = bookmarks.find((e) => e.kind === (category ? NDKKind.BookmarkSet : NDKKind.BookmarkList)); - if (!existingBookmarks) { - throw new Error('No existing bookmarks found'); + if (!bookmarkEvent) { + throw new Error('Bookmark not found'); } - const bookmarkEvent = existingBookmarks.find((event) => { - const isMatchingCategory = category - ? event.tags.some(tag => tag[0] === 'd' && tag[1] === category) - : true; - - return isMatchingCategory && event.tags.some(tag => tag[0] === 'e' && tag[1] === eventId); - }); + // Resetting the id and created_at to avoid conflicts + bookmarkEvent.id = undefined as any; + bookmarkEvent.created_at = undefined; - if (bookmarkEvent) { - bookmarkEvent.tags = bookmarkEvent.tags.filter(tag => !(tag[0] === 'e' && tag[1] === eventId)); - if (bookmarkEvent.tags.length > 0) { - await bookmarkEvent.publish(); - } - } else { - throw new Error('Bookmark not found'); + if (category) { + bookmarkEvent.tags = bookmarkEvent.tags.filter(tag => !(tag[0] === 'd' && tag[1] === category)); } + + // Remove the specific event + bookmarkEvent.tags = bookmarkEvent.tags.filter(tag => !(tag[0] === 'e' && tag[1] === eventId)); + + + await bookmarkEvent.sign(); + await bookmarkEvent.publish(); + // if (bookmarkEvent.tags.length > 0) { + // await bookmarkEvent.sign(); + // await bookmarkEvent.publish(); + // } }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['bookmarks', userPublicKey] }); @@ -100,7 +183,8 @@ export const useBookmark = (userPublicKey: string) => { return { bookmarkNote: bookmarkNote.mutateAsync, removeBookmark: removeBookmark.mutateAsync, - getBookmarks: getBookmarks.data, - isFetchingBookmarks: getBookmarks.isFetching, + bookmarks: bookmarks.data, + isFetchingBookmarks: bookmarks.isFetching, + bookmarksWithNotes: bookmarksWithNotesQuery.data }; }; diff --git a/packages/indexer-prisma/prisma/schema.prisma b/packages/indexer-prisma/prisma/schema.prisma index 26f02a6e..e1b17cf2 100644 --- a/packages/indexer-prisma/prisma/schema.prisma +++ b/packages/indexer-prisma/prisma/schema.prisma @@ -21,6 +21,7 @@ model token_deploy { total_supply String? created_at DateTime? @default(now()) @db.Timestamp(6) cursor BigInt? @map("_cursor") + quote_token String? } model token_launch { @@ -104,3 +105,25 @@ model unrugmeme_transfers { created_at DateTime? @default(now()) @db.Timestamp(6) cursor BigInt? @map("_cursor") } + +model token_transactions { + transfer_id String? + network String? + block_hash String? + block_number String? + block_timestamp String? + memecoin_address String? + transaction_hash String @id + time_stamp String? + last_price String? + current_supply String? + total_supply String? + coin_received String? + initial_supply String? + protocol_fee String? + liquidity_raised String? + price String? + amount String? + transaction_type String? + cursor BigInt? @map("_cursor") +} diff --git a/packages/pixel_ui/index.js b/packages/pixel_ui/index.js index d70cdca6..d1ec4477 100644 --- a/packages/pixel_ui/index.js +++ b/packages/pixel_ui/index.js @@ -1,2 +1,3 @@ -export * from "./src/App" +// export * from "./src/App" +export * from "./src/App.tsx" // export * from "./src" \ No newline at end of file diff --git a/packages/pixel_ui/src/App.js b/packages/pixel_ui/src/App.js index ba194aef..86be9f1d 100644 --- a/packages/pixel_ui/src/App.js +++ b/packages/pixel_ui/src/App.js @@ -23,7 +23,6 @@ import NotificationPanel from './tabs/NotificationPanel.js'; import ModalPanel from './ui/ModalPanel.js'; import Hamburger from './resources/icons/Hamburger.png'; import useMediaQuery from './hooks/useMediaQuery'; -// import { useMediaQuery } from 'react-responsive'; function App() { // Window management diff --git a/packages/pixel_ui/src/App.tsx b/packages/pixel_ui/src/App.tsx new file mode 100644 index 00000000..658424e8 --- /dev/null +++ b/packages/pixel_ui/src/App.tsx @@ -0,0 +1,838 @@ +import React, { useState, useEffect, useRef, useCallback } from 'react'; +import useWebSocket, { ReadyState } from 'react-use-websocket'; +import { + useAccount, + useContract, + useNetwork, + useConnect +} from '@starknet-react/core'; +import './App.css'; +import CanvasContainer from './canvas/CanvasContainer.js'; +import PixelSelector from './footer/PixelSelector.js'; +import TabsFooter from './footer/TabsFooter.js'; +import TabPanel from './tabs/TabPanel.js'; +import { usePreventZoom, useLockScroll } from './utils/Window.js'; +import { backendUrl, wsUrl, devnetMode } from './utils/Consts.js'; +import logo from './resources/logo.png'; +import canvasConfig from './configs/canvas.config.json'; +import { fetchWrapper, getTodaysStartTime } from './services/apiService.js'; +import art_peace_abi from './contracts/art_peace.abi.json'; +import username_store_abi from './contracts/username_store.abi.json'; +import canvas_nft_abi from './contracts/canvas_nft.abi.json'; +import NotificationPanel from './tabs/NotificationPanel.js'; +import ModalPanel from './ui/ModalPanel.js'; +import Hamburger from './resources/icons/Hamburger.png'; +import useMediaQuery from './hooks/useMediaQuery.js'; +// import { useMediaQuery } from 'react-responsive'; + +interface IApp { + contractAddress?: string; + canvasAddress?: string; + nftAddress?: string; + factoryAddress?: string; +} + +function App({ contractAddress, canvasAddress, nftAddress, factoryAddress }: IApp) { + // Window management + usePreventZoom(); + const tabs = ['Canvas', 'Factions', 'Quests', 'Vote', 'NFTs', 'Account']; + const [activeTab, setActiveTab] = useState(tabs[0]); + useLockScroll(activeTab === 'Canvas'); + + const isDesktopOrLaptop = useMediaQuery({ + query: '(min-width: 1224px)' + }); + const isBigScreen = useMediaQuery({ query: '(min-width: 1824px)' }); + const isTabletOrMobile = useMediaQuery({ query: '(max-width: 1224px)' }); + const isPortrait = useMediaQuery({ query: '(orientation: portrait)' }); + const isRetina = useMediaQuery({ query: '(min-resolution: 2dppx)' }); + const isMobile = useMediaQuery({ query: '(max-width: 768px)' }); + const isFooterSplit = useMediaQuery({ query: '(max-width: 52rem)' }); + // TODO: height checks ? + // TODO: Animate logo exit on mobile + + const [footerExpanded, setFooterExpanded] = useState(false); + const [modal, setModal] = useState(null); + + const getDeviceTypeInfo = () => { + return { + isDesktopOrLaptop: isDesktopOrLaptop, + isBigScreen: isBigScreen, + isTabletOrMobile: isTabletOrMobile, + isPortrait: isPortrait, + isRetina: isRetina, + isMobile: isMobile + }; + }; + + // Starknet wallet + const { account, address } = useAccount(); + const { chain } = useNetwork(); + const [queryAddress, setQueryAddress] = useState('0'); + const [connected, setConnected] = useState(false); // TODO: change to only devnet + useEffect(() => { + if (devnetMode) { + if (connected) { + setQueryAddress( + '0328ced46664355fc4b885ae7011af202313056a7e3d44827fb24c9d3206aaa0' + ); + } else { + setQueryAddress('0'); + } + } else { + if (!address) { + setQueryAddress('0'); + } else { + setQueryAddress(address.slice(2).toLowerCase().padStart(64, '0')); + } + } + }, [address, connected]); + + // Contracts + // TODO: Pull addrs from api? + const { contract: artPeaceContract } = useContract({ + address: process.env.REACT_APP_STARKNET_CONTRACT_ADDRESS, + abi: art_peace_abi + }); + const { contract: usernameContract } = useContract({ + address: process.env.REACT_APP_USERNAME_STORE_CONTRACT_ADDRESS, + abi: username_store_abi + }); + const { contract: canvasNftContract } = useContract({ + address: process.env.REACT_APP_CANVAS_NFT_CONTRACT_ADDRESS, + abi: canvas_nft_abi + }); + + const [currentDay, setCurrentDay] = useState(0); + const [isLastDay, setIsLastDay] = useState(false); + const [gameEnded, setGameEnded] = useState(false); + const [host, setHost] = useState(''); + const [endTimestamp, setEndTimestamp] = useState(0); + useEffect(() => { + const fetchGameData = async () => { + let response = await fetchWrapper('get-game-data'); + if (!response.data) { + return; + } + setCurrentDay(response.data.day); + if (devnetMode) { + const days = 4; + if (response.data.day >= days) { + setGameEnded(true); + } else if (response.data.day === days - 1) { + setIsLastDay(true); + } + } else { + let now = new Date(); + const result = await getTodaysStartTime(); + let dayEnd = new Date(result.data); + dayEnd.setHours(dayEnd.getHours() + 24); + // Now in seconds + let nowInSeconds = Math.floor(now.getTime() / 1000); + let dayEndInSeconds = Math.floor(dayEnd.getTime() / 1000); + if (nowInSeconds >= response.data.endTime) { + setGameEnded(true); + } else if (dayEndInSeconds >= response.data.endTime) { + setIsLastDay(true); + } + } + setHost(response.data.host); + setEndTimestamp(response.data.endTime); + }; + fetchGameData(); + }, []); + + // Websocket + const { sendJsonMessage, lastJsonMessage, readyState } = useWebSocket(wsUrl, { + share: false, + shouldReconnect: (_e) => true, + reconnectAttempts: 10, + reconnectInterval: (attempt) => Math.min(10000, Math.pow(2, attempt) * 1000) + }); + const [latestMintedTokenId, setLatestMintedTokenId] = useState(null); + + useEffect(() => { + if (readyState === ReadyState.OPEN) { + sendJsonMessage({ + event: 'subscribe', + data: { + channel: 'general' + } + }); + } + }, [readyState]); + + // Colors + const staticColors = canvasConfig.colors; + const [colors, setColors] = useState([]); + + const [notificationMessage, setNotificationMessage] = useState(''); + + const fetchColors = async () => { + try { + let getColorsEndpoint = backendUrl + '/get-colors'; + let response = await fetch(getColorsEndpoint); + let colors = await response.json(); + if (colors.error) { + setColors(staticColors); + console.error(colors.error); + return; + } + if (colors.data) { + setColors(colors.data); + } + } catch (error) { + setColors(staticColors); + console.error(error); + } + }; + useEffect(() => { + fetchColors(); + }, []); + + useEffect(() => { + const processMessage = async (message) => { + if (message) { + // Check the message type and handle accordingly + if (message.messageType === 'colorPixel') { + if (message.color >= colors.length) { + // Get new colors from backend + await fetchColors(); + } + colorPixel(message.position, message.color); + } else if ( + message.messageType === 'nftMinted' && + activeTab === 'NFTs' + ) { + if (message.minter === queryAddress) { + setLatestMintedTokenId(message.token_id); + } + } + } + }; + + processMessage(lastJsonMessage); + }, [lastJsonMessage]); + + // Canvas + const width = canvasConfig.canvas.width; + const height = canvasConfig.canvas.height; + + const canvasRef = useRef(null); + const extraPixelsCanvasRef = useRef(null); + + const colorPixel = (position, color) => { + const canvas = canvasRef.current; + const context = canvas.getContext('2d'); + const x = position % width; + const y = Math.floor(position / width); + const colorIdx = color; + const colorHex = `#${colors[colorIdx]}FF`; + context.fillStyle = colorHex; + context.fillRect(x, y, 1, 1); + }; + + // Pixel selection data + const [selectedColorId, setSelectedColorId] = useState(-1); + const [pixelSelectedMode, setPixelSelectedMode] = useState(false); + const [selectedPositionX, setSelectedPositionX] = useState(null); + const [selectedPositionY, setSelectedPositionY] = useState(null); + const [pixelPlacedBy, setPixelPlacedBy] = useState(''); + + const [lastPlacedTime, setLastPlacedTime] = useState(0); + const [basePixelUp, setBasePixelUp] = useState(false); + const [chainFactionPixelsData, setChainFactionPixelsData] = useState([]); + const [chainFactionPixels, setChainFactionPixels] = useState([]); + const [factionPixelsData, setFactionPixelsData] = useState([]); + const [factionPixels, setFactionPixels] = useState([]); + const [extraPixels, setExtraPixels] = useState(0); + const [availablePixels, setAvailablePixels] = useState(0); + const [availablePixelsUsed, setAvailablePixelsUsed] = useState(0); + const [extraPixelsData, setExtraPixelsData] = useState([]); + + const [selectorMode, setSelectorMode] = useState(false); + + const [isEraserMode, setIsEraserMode] = React.useState(false); + const [isExtraDeleteMode, setIsExtraDeleteMode] = React.useState(false); + + useEffect(() => { + const getLastPlacedPixel = `get-last-placed-time?address=${queryAddress}`; + async function fetchGetLastPlacedPixel() { + const response = await fetchWrapper(getLastPlacedPixel); + if (!response.data) { + return; + } + const time = new Date(response.data); + setLastPlacedTime(time?.getTime()); + } + + fetchGetLastPlacedPixel(); + }, [queryAddress]); + + const updateInterval = 1000; // 1 second + // TODO: make this a config + const timeBetweenPlacements = 120000; // 2 minutes + const [basePixelTimer, setBasePixelTimer] = useState('XX:XX'); + useEffect(() => { + const updateBasePixelTimer = () => { + let timeSinceLastPlacement = Date.now() - lastPlacedTime; + let basePixelAvailable = timeSinceLastPlacement > timeBetweenPlacements; + if (basePixelAvailable) { + setBasePixelUp(true); + setBasePixelTimer('00:00'); + clearInterval(interval); + } else { + let secondsTillPlacement = Math.floor( + (timeBetweenPlacements - timeSinceLastPlacement) / 1000 + ); + setBasePixelTimer( + `${Math.floor(secondsTillPlacement / 60)}:${secondsTillPlacement % 60 < 10 ? '0' : ''}${secondsTillPlacement % 60}` + ); + setBasePixelUp(false); + } + }; + const interval = setInterval(() => { + updateBasePixelTimer(); + }, updateInterval); + updateBasePixelTimer(); + return () => clearInterval(interval); + }, [lastPlacedTime]); + + const [chainFactionPixelTimers, setChainFactionPixelTimers] = useState([]); + useEffect(() => { + const updateChainFactionPixelTimers = () => { + let newChainFactionPixelTimers = []; + let newChainFactionPixels = []; + for (let i = 0; i < chainFactionPixelsData.length; i++) { + let memberPixels = chainFactionPixelsData[i].memberPixels; + if (memberPixels !== 0) { + newChainFactionPixelTimers.push('00:00'); + newChainFactionPixels.push(memberPixels); + continue; + } + let lastPlacedTime = new Date(chainFactionPixelsData[i].lastPlacedTime); + let timeSinceLastPlacement = Date.now() - lastPlacedTime?.getTime(); + let chainFactionPixelAvailable = + timeSinceLastPlacement > timeBetweenPlacements; + if (chainFactionPixelAvailable) { + newChainFactionPixelTimers.push('00:00'); + newChainFactionPixels.push(chainFactionPixelsData[i].allocation); + } else { + let secondsTillPlacement = Math.floor( + (timeBetweenPlacements - timeSinceLastPlacement) / 1000 + ); + newChainFactionPixelTimers.push( + `${Math.floor(secondsTillPlacement / 60)}:${secondsTillPlacement % 60 < 10 ? '0' : ''}${secondsTillPlacement % 60}` + ); + newChainFactionPixels.push(0); + } + } + setChainFactionPixelTimers(newChainFactionPixelTimers); + setChainFactionPixels(newChainFactionPixels); + }; + const interval = setInterval(() => { + updateChainFactionPixelTimers(); + }, updateInterval); + updateChainFactionPixelTimers(); + return () => clearInterval(interval); + }, [chainFactionPixelsData]); + + const [factionPixelTimers, setFactionPixelTimers] = useState([]); + useEffect(() => { + const updateFactionPixelTimers = () => { + let newFactionPixelTimers = []; + let newFactionPixels = []; + for (let i = 0; i < factionPixelsData.length; i++) { + let memberPixels = factionPixelsData[i].memberPixels; + if (memberPixels !== 0) { + newFactionPixelTimers.push('00:00'); + newFactionPixels.push(memberPixels); + continue; + } + let lastPlacedTime = new Date(factionPixelsData[i].lastPlacedTime); + let timeSinceLastPlacement = Date.now() - lastPlacedTime?.getTime(); + let factionPixelAvailable = + timeSinceLastPlacement > timeBetweenPlacements; + if (factionPixelAvailable) { + newFactionPixelTimers.push('00:00'); + newFactionPixels.push(factionPixelsData[i].allocation); + } else { + let secondsTillPlacement = Math.floor( + (timeBetweenPlacements - timeSinceLastPlacement) / 1000 + ); + newFactionPixelTimers.push( + `${Math.floor(secondsTillPlacement / 60)}:${secondsTillPlacement % 60 < 10 ? '0' : ''}${secondsTillPlacement % 60}` + ); + newFactionPixels.push(0); + } + } + setFactionPixelTimers(newFactionPixelTimers); + setFactionPixels(newFactionPixels); + }; + const interval = setInterval(() => { + updateFactionPixelTimers(); + }, updateInterval); + updateFactionPixelTimers(); + return () => clearInterval(interval); + }, [factionPixelsData]); + + useEffect(() => { + let totalChainFactionPixels = 0; + for (let i = 0; i < chainFactionPixels.length; i++) { + totalChainFactionPixels += chainFactionPixels[i]; + } + let totalFactionPixels = 0; + for (let i = 0; i < factionPixels.length; i++) { + totalFactionPixels += factionPixels[i]; + } + setAvailablePixels( + (basePixelUp ? 1 : 0) + + totalChainFactionPixels + + totalFactionPixels + + extraPixels + ); + }, [basePixelUp, chainFactionPixels, factionPixels, extraPixels]); + + useEffect(() => { + async function fetchExtraPixelsEndpoint() { + let extraPixelsResponse = await fetchWrapper( + `get-extra-pixels?address=${queryAddress}` + ); + if (!extraPixelsResponse.data) { + setExtraPixels(0); + return; + } + setExtraPixels(extraPixelsResponse.data); + } + fetchExtraPixelsEndpoint(); + + async function fetchChainFactionPixelsEndpoint() { + let chainFactionPixelsResponse = await fetchWrapper( + `get-chain-faction-pixels?address=${queryAddress}` + ); + if (!chainFactionPixelsResponse.data) { + setChainFactionPixelsData([]); + return; + } + setChainFactionPixelsData(chainFactionPixelsResponse.data); + } + fetchChainFactionPixelsEndpoint(); + + async function fetchFactionPixelsEndpoint() { + let factionPixelsResponse = await fetchWrapper( + `get-faction-pixels?address=${queryAddress}` + ); + if (!factionPixelsResponse.data) { + setFactionPixelsData([]); + return; + } + setFactionPixelsData(factionPixelsResponse.data); + } + fetchFactionPixelsEndpoint(); + }, [queryAddress]); + + const clearPixelSelection = () => { + setSelectedColorId(-1); + setSelectedPositionX(null); + setSelectedPositionY(null); + setPixelSelectedMode(false); + setPixelPlacedBy(''); + }; + + const setPixelSelection = (x, y) => { + setSelectedPositionX(x); + setSelectedPositionY(y); + setPixelSelectedMode(true); + // TODO: move http fetch for pixel data here? + }; + + const clearExtraPixels = useCallback(() => { + setAvailablePixelsUsed(0); + setExtraPixelsData([]); + + const canvas = extraPixelsCanvasRef.current; + const context = canvas.getContext('2d'); + context.clearRect(0, 0, width, height); + }, [width, height]); + + const clearExtraPixel = useCallback( + (index) => { + setAvailablePixelsUsed(availablePixelsUsed - 1); + setExtraPixelsData(extraPixelsData.filter((_, i) => i !== index)); + const canvas = extraPixelsCanvasRef.current; + const context = canvas.getContext('2d'); + const pixel = extraPixelsData[index]; + const x = pixel.x; + const y = pixel.y; + context.clearRect(x, y, 1, 1); + }, + [extraPixelsData, availablePixelsUsed] + ); + + const addExtraPixel = useCallback( + (x, y) => { + // Overwrite pixel if already placed + const existingPixelIndex = extraPixelsData.findIndex( + (pixel) => pixel.x === x && pixel.y === y + ); + if (existingPixelIndex !== -1) { + let newExtraPixelsData = [...extraPixelsData]; + newExtraPixelsData[existingPixelIndex].colorId = selectedColorId; + setExtraPixelsData(newExtraPixelsData); + } else { + setAvailablePixelsUsed(availablePixelsUsed + 1); + setExtraPixelsData([ + ...extraPixelsData, + { x: x, y: y, colorId: selectedColorId } + ]); + } + }, + [extraPixelsData, availablePixelsUsed, selectedColorId] + ); + + // Factions + const [chainFaction, setChainFaction] = useState(null); + const [userFactions, setUserFactions] = useState([]); + useEffect(() => { + async function fetchChainFaction() { + let chainFactionResponse = await fetchWrapper( + `get-my-chain-factions?address=${queryAddress}` + ); + if (!chainFactionResponse.data) { + return; + } + if (chainFactionResponse.data.length === 0) { + return; + } + setChainFaction(chainFactionResponse.data[0]); + } + async function fetchUserFactions() { + let userFactionsResponse = await fetchWrapper( + `get-my-factions?address=${queryAddress}` + ); + if (!userFactionsResponse.data) { + return; + } + setUserFactions(userFactionsResponse.data); + } + fetchChainFaction(); + fetchUserFactions(); + }, [queryAddress]); + + // Templates + const [templateOverlayMode, setTemplateOverlayMode] = useState(false); + const [overlayTemplate, setOverlayTemplate] = useState(null); + + const [templateFaction, setTemplateFaction] = useState(null); + const [templateImage, setTemplateImage] = useState(null); + const [templateColorIds, setTemplateColorIds] = useState([]); + const [templateCreationMode, setTemplateCreationMode] = useState(false); + const [templateCreationSelected, setTemplateCreationSelected] = + useState(false); + const [templatePosition, setTemplatePosition] = useState(0); + + // NFTs + const [nftMintingMode, setNftMintingMode] = useState(false); + const [nftSelectionStarted, setNftSelectionStarted] = useState(false); + const [nftSelected, setNftSelected] = useState(false); + const [nftPosition, setNftPosition] = useState(null); + const [nftWidth, setNftWidth] = useState(null); + const [nftHeight, setNftHeight] = useState(null); + + // Account + const { connect, connectors } = useConnect(); + const connectWallet = async (connector) => { + if (devnetMode) { + setConnected(true); + return; + } + connect({ connector }); + }; + useEffect(() => { + if (devnetMode) return; + if (!connectors) return; + if (connectors.length === 0) return; + + const connectIfReady = async () => { + for (let i = 0; i < connectors.length; i++) { + let ready = await connectors[i].ready(); + if (ready) { + connectWallet(connectors[i]); + break; + } + } + }; + connectIfReady(); + }, [connectors]); + + // Tabs + const [showExtraPixelsPanel, setShowExtraPixelsPanel] = useState(false); + + useEffect(() => { + // TODO: If selecting into other tab, ask to stop selecting? + if (activeTab !== tabs[0] && showExtraPixelsPanel) { + clearExtraPixels(); + setSelectedColorId(-1); + setShowExtraPixelsPanel(false); + return; + } + + if (selectedColorId !== -1) { + if (availablePixels > (basePixelUp ? 1 : 0)) { + setActiveTab(tabs[0]); + setShowExtraPixelsPanel(true); + return; + } else { + setShowExtraPixelsPanel(false); + return; + } + } else { + if (availablePixelsUsed > 0) { + setActiveTab(tabs[0]); + setShowExtraPixelsPanel(true); + return; + } else { + setShowExtraPixelsPanel(false); + return; + } + } + }, [ + activeTab, + selectedColorId, + availablePixels, + availablePixelsUsed, + basePixelUp + ]); + + return ( +
+
+ + {modal && } + + {(!isMobile || activeTab === tabs[0]) && ( + logo + )} +
+ +
+
+
+ {!gameEnded && ( + + )} + {isFooterSplit && !footerExpanded && ( +
{ + setActiveTab(tabs[0]); + setFooterExpanded(!footerExpanded); + }} + > + Tabs +
+ )} + {isFooterSplit && footerExpanded && ( + + )} +
+ {!isFooterSplit && ( + + )} +
+
+
+ ); +} + +export default App; diff --git a/packages/pixel_ui/src/canvas/TemplateOverlay.js b/packages/pixel_ui/src/canvas/TemplateOverlay.js index f8b795a3..79f48357 100644 --- a/packages/pixel_ui/src/canvas/TemplateOverlay.js +++ b/packages/pixel_ui/src/canvas/TemplateOverlay.js @@ -27,7 +27,7 @@ const TemplateOverlay = (props) => { const imageData = ctx.getImageData(0, 0, image.width, image.height); const data = imageData.data; - let imagePalleteIds = []; + let imagePaletteIds = []; // Convert image data to color palette for (let i = 0; i < data.length; i += 4) { if (data[i + 3] < 128) { @@ -35,7 +35,7 @@ const TemplateOverlay = (props) => { data[i + 1] = 255; data[i + 2] = 255; data[i + 3] = 0; - imagePalleteIds.push(255); + imagePaletteIds.push(255); continue; } let minDistance = 1000000; @@ -59,7 +59,7 @@ const TemplateOverlay = (props) => { data[i] = minColor[0]; data[i + 1] = minColor[1]; data[i + 2] = minColor[2]; - imagePalleteIds.push(minColorIndex); + imagePaletteIds.push(minColorIndex); } // Set image data back to canvas diff --git a/packages/pixel_ui/src/configs/backend.config copy.json b/packages/pixel_ui/src/configs/backend.config copy.json index e9f57c37..9636245b 100644 --- a/packages/pixel_ui/src/configs/backend.config copy.json +++ b/packages/pixel_ui/src/configs/backend.config copy.json @@ -1,6 +1,7 @@ { "host": "api.art-peace.net", "port": 8080, + "consumer_port": 8081, "scripts": { "place_pixel_devnet": "../tests/integration/local/place_pixel.sh", "place_extra_pixels_devnet": "../tests/integration/local/place_extra_pixels.sh", diff --git a/packages/pixel_ui/src/configs/backend.config.json b/packages/pixel_ui/src/configs/backend.config.json index 69a04867..12528cd7 100644 --- a/packages/pixel_ui/src/configs/backend.config.json +++ b/packages/pixel_ui/src/configs/backend.config.json @@ -1,14 +1,14 @@ { - "host_local": "localhost", - "host": "https://backend-pixel.onrender.com/", + "host": "backend-pixel.onrender.com", "port": 8082, + "consumer_port": 8081, "scripts": { "place_pixel_devnet": "../tests/integration/local/place_pixel.sh", "place_extra_pixels_devnet": "../tests/integration/local/place_extra_pixels.sh", "add_template_devnet": "../tests/integration/local/add_template.sh", "mint_nft_devnet": "../tests/integration/local/mint_nft.sh" }, - "production": true, + "production": false, "websocket": { "read_buffer_size": 1024, "write_buffer_size": 1024 diff --git a/packages/pixel_ui/src/configs/backend.dev.config.json b/packages/pixel_ui/src/configs/backend.dev.config.json new file mode 100644 index 00000000..9b82be15 --- /dev/null +++ b/packages/pixel_ui/src/configs/backend.dev.config.json @@ -0,0 +1,16 @@ +{ + "host": "localhost", + "port": 8082, + "consumer_port": 8081, + "scripts": { + "place_pixel_devnet": "../tests/integration/local/place_pixel.sh", + "place_extra_pixels_devnet": "../tests/integration/local/place_extra_pixels.sh", + "add_template_devnet": "../tests/integration/local/add_template.sh", + "mint_nft_devnet": "../tests/integration/local/mint_nft.sh" + }, + "production": false, + "websocket": { + "read_buffer_size": 1024, + "write_buffer_size": 1024 + } +} diff --git a/packages/pixel_ui/src/configs/canvas.config.json b/packages/pixel_ui/src/configs/canvas.config.json index f16844e6..0575dc73 100644 --- a/packages/pixel_ui/src/configs/canvas.config.json +++ b/packages/pixel_ui/src/configs/canvas.config.json @@ -37,7 +37,6 @@ "3F00EF", "1991F4", "5672E1", - "786EDE", "3C3C84", "C84CF5", "CDA3F5", diff --git a/packages/pixel_ui/src/services/apiService.js b/packages/pixel_ui/src/services/apiService.js index 50bd4bf6..784d112a 100644 --- a/packages/pixel_ui/src/services/apiService.js +++ b/packages/pixel_ui/src/services/apiService.js @@ -4,6 +4,7 @@ export const fetchWrapper = async (url, options = {}) => { const controller = new AbortController(); const signal = controller.signal; try { + console.log("backendUrl",backendUrl) const response = await fetch(`${backendUrl}/${url}`, { mode: 'cors', signal, diff --git a/packages/pixel_ui/src/utils/Consts.js b/packages/pixel_ui/src/utils/Consts.js index ea4c5b22..64ed13f1 100644 --- a/packages/pixel_ui/src/utils/Consts.js +++ b/packages/pixel_ui/src/utils/Consts.js @@ -1,29 +1,60 @@ -import backendConfig from '../configs/backend.config.json'; - +import backendConfigProd from '../configs/backend.config.json'; +import backendConfigDev from '../configs/backend.dev.config.json'; +const isProduction = process.env.REACT_APP_NODE_ENV == "true" ? true : false +/** TODO add ENV and config for prod and test */ /** TODO fix url */ -export const backendUrl = 'https://' + backendConfig.host; -// export const backendUrl = 'https://' + backendConfig.host + ':' + backendConfig.port; -// export const backendUrl = backendConfig.production -// ? 'https://' + backendConfig.host -// : 'http://' + backendConfig.host + ':' + backendConfig.port; +// const backendConfig = isProduction ? backendConfigProd : backendConfigDev +const backendConfig = backendConfigProd +// const backendConfig = backendConfigDev + +// TODO used REACT_APP_NODE_ENV +// const isProduction = true +// export const backendUrl = 'https://' + backendConfig.host; +console.log("isProduction", isProduction) +console.log("REACT_APP_BACKEND_URL", process.env.REACT_APP_BACKEND_URL) +// export const backendUrl = process.env.REACT_APP_BACKEND_URL ?? backendConfig.host; +// export const backendUrl = process.env.REACT_APP_BACKEND_URL ? 'https://' + process.env.REACT_APP_BACKEND_URL : 'https://' + backendConfig.host + ':' + backendConfig.port; +// console.log("backendUrl", backendUrl) +export const backendUrl = isProduction + ? 'https://' + typeof process.env.REACT_APP_BACKEND_URL !== "undefined" ? process.env.REACT_APP_BACKEND_URL : backendConfig.host + : 'http://' + backendConfig.host + ':' + backendConfig.port; +console.log("backendUrl", backendUrl) -export const wsUrl = backendConfig.production +export const wsUrl = isProduction ? 'wss://' + backendConfig.host + '/ws' : 'ws://' + backendConfig.host + ':' + backendConfig.consumer_port + '/ws'; +console.log("wsUrl", wsUrl) -export const nftUrl = backendConfig.production - ? 'https://' + backendConfig.host +export const nftUrl = isProduction + ? 'https://' + typeof process.env.REACT_APP_BACKEND_URL !== "undefined" ? process.env.REACT_APP_BACKEND_URL : backendConfig.host : 'http://' + backendConfig.host + ':' + backendConfig.consumer_port; -export const templateUrl = backendConfig.production - ? 'https://' + backendConfig.host - : 'http://' + backendConfig.host + ':' + backendConfig.port; -export const devnetMode = backendConfig.production === false; +export const templateUrl = isProduction + ? 'https://' + typeof process.env.REACT_APP_BACKEND_URL !== "undefined" ? process.env.REACT_APP_BACKEND_URL : backendConfig.host + : 'http://' + typeof process.env.REACT_APP_BACKEND_URL !== "undefined" ? process.env.REACT_APP_BACKEND_URL : backendConfig.host + ':' + backendConfig.port; + +// export const wsUrl = backendConfig.production +// ? 'wss://' + backendConfig.host + '/ws' +// : 'ws://' + backendConfig.host + ':' + backendConfig.consumer_port + '/ws'; +// console.log("wsUrl", wsUrl) + +// export const nftUrl = backendConfig.production +// ? 'https://' + typeof process.env.REACT_APP_BACKEND_URL !== "undefined" ? process.env.REACT_APP_BACKEND_URL : backendConfig.host +// : 'http://' + backendConfig.host + ':' + backendConfig.consumer_port; + +console.log("nftUrl", nftUrl) + +// export const templateUrl = backendConfig.production +// ? 'https://' + typeof process.env.REACT_APP_BACKEND_URL !== "undefined" ? process.env.REACT_APP_BACKEND_URL : backendConfig.host +// : 'http://' + typeof process.env.REACT_APP_BACKEND_URL !== "undefined" ? process.env.REACT_APP_BACKEND_URL : backendConfig.host + ':' + backendConfig.port; + +// TODO used REACT_APP_NODE_ENV +export const devnetMode = backendConfig.production === false; export const convertUrl = (url) => { if (!url) { return url; diff --git a/packages/pixel_ui/tsconfig.json b/packages/pixel_ui/tsconfig.json new file mode 100644 index 00000000..f27b54c2 --- /dev/null +++ b/packages/pixel_ui/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src", + "composite": true, + "target": "ES6", + "module": "ES6", + "lib": ["dom", "dom.iterable", "esnext"], + "jsx": "react-jsx", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "allowJs": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "noFallthroughCasesInSwitch": true, + "skipLibCheck": true, + "declaration": true, + "sourceMap": true, + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"], + +} \ No newline at end of file diff --git a/turbo.json b/turbo.json index 4dc0b337..db4793e3 100644 --- a/turbo.json +++ b/turbo.json @@ -20,7 +20,9 @@ "TELEGRAM_WEB_APP", "TG_ADMIN_CHAT_ID", "TELEGRAM_MOBILE_APP", - "EXPO_PUBLIC_PIXEL_URL" + "EXPO_PUBLIC_PIXEL_URL", + "REACT_APP_BACKEND_URL", + "REACT_APP_NODE_ENV" ], "pipeline": { "build": { @@ -44,7 +46,9 @@ "TELEGRAM_WEB_APP", "INDEXER_DATABASE_URL", "TELEGRAM_MOBILE_APP", - "EXPO_PUBLIC_PIXEL_URL" + "EXPO_PUBLIC_PIXEL_URL", + "REACT_APP_BACKEND_URL", + "REACT_APP_NODE_ENV" ] }, "run": { @@ -58,7 +62,9 @@ "NEXT_PUBLIC_PINATA_JWT", "APP_URL", "NEXT_PUBLIC_WALLET_CONNECT_ID", - "EXPO_PUBLIC_PIXEL_URL" + "EXPO_PUBLIC_PIXEL_URL", + "REACT_APP_BACKEND_URL", + "REACT_APP_NODE_ENV" ] }, "deploy": {