diff --git a/.dockerignore b/.dockerignore index 5779b5a6..b3350cee 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,3 +2,5 @@ **/node_modules **/dist **/dist/* +.husky/ +.vscode/ diff --git a/Tiltfile b/Tiltfile index cde1a833..407b65e5 100644 --- a/Tiltfile +++ b/Tiltfile @@ -5,7 +5,7 @@ docker_compose('docker-compose.yml') docker_build('ditherchat/api-main', '.', dockerfile = './packages/api-main/Dockerfile', live_update = [ - sync('./packages/api-main/src', '/app'), + sync('./packages/api-main/src', '/app/packages/api-main/src'), run('bun install', trigger='package.json'), restart_container(), ]) @@ -13,7 +13,7 @@ docker_build('ditherchat/api-main', '.', docker_build('ditherchat/reader-main', '.', dockerfile = './packages/reader-main/Dockerfile', live_update = [ - sync('./packages/reader-main/src', '/app'), + sync('./packages/reader-main/src', '/app/packages/reader-main/src'), run('bun install', trigger='package.json'), restart_container(), ]) diff --git a/docker-compose.yml b/docker-compose.yml index 846fda29..da6d61a1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -72,6 +72,8 @@ services: RECEIVER: atone1uq6zjslvsa29cy6uu75y8txnl52mw06j6fzlep API_ROOT: 'http://api-main:3000/v1' AUTH: dev + MIN_REGISTER_HANDLE_FEE: '0.000001' + # Uncomment to enable fast sync # ECLESIA_GRAPHQL_ENDPOINT: "https://graphql-atomone-testnet-1.allinbits.services/v1/graphql" # ECLESIA_GRAPHQL_SECRET: "" diff --git a/packages/api-main/drizzle/db.ts b/packages/api-main/drizzle/db.ts index b9684287..471e30bf 100644 --- a/packages/api-main/drizzle/db.ts +++ b/packages/api-main/drizzle/db.ts @@ -6,11 +6,13 @@ import pg from 'pg'; dotenv.config(); +export type DbClient = ReturnType; + const { Pool } = pg; -let db: ReturnType; +let db: DbClient; -export function getDatabase() { +export function getDatabase(): DbClient { if (!db) { const client = new Pool({ connectionString: process.env.PG_URI!, diff --git a/packages/api-main/drizzle/schema.ts b/packages/api-main/drizzle/schema.ts index 80cde5ba..660202d0 100644 --- a/packages/api-main/drizzle/schema.ts +++ b/packages/api-main/drizzle/schema.ts @@ -1,8 +1,36 @@ import { sql } from 'drizzle-orm'; -import { bigint, boolean, index, integer, pgEnum, pgTable, primaryKey, serial, text, timestamp, varchar } from 'drizzle-orm/pg-core'; +import { bigint, boolean, index, integer, pgEnum, pgTable, primaryKey, serial, text, timestamp, unique, varchar } from 'drizzle-orm/pg-core'; const MEMO_LENGTH = 512; +export const AccountTable = pgTable( + 'account', + { + address: varchar({ length: 44 }).primaryKey(), + handle: varchar({ length: 25 }), + display: varchar({ length: 128 }), + }, + t => [ + unique('account_handle_idx').on(t.handle), + index('account_display_idx').on(t.display), + ], +); + +export const HandleTransferTable = pgTable( + 'handle_transfer', + { + hash: varchar({ length: 64 }).notNull(), + name: varchar({ length: 32 }).notNull(), + from_address: varchar({ length: 44 }).notNull(), + to_address: varchar({ length: 44 }).notNull(), + accepted: boolean().default(false).notNull(), + timestamp: timestamp({ withTimezone: true }).notNull(), + }, + t => [ + index('handle_transfer_to_idx').on(t.name, t.to_address), + ], +); + export const FeedTable = pgTable( 'feed', { @@ -129,7 +157,15 @@ export const ModeratorTable = pgTable('moderators', { deleted_at: timestamp({ withTimezone: true }), }); -export const notificationTypeEnum = pgEnum('notification_type', ['like', 'dislike', 'flag', 'follow', 'reply']); +export const notificationTypeEnum = pgEnum('notification_type', [ + 'like', + 'dislike', + 'flag', + 'follow', + 'reply', + 'registerHandle', + 'transferHandle', +]); export const NotificationTable = pgTable( 'notifications', @@ -166,4 +202,6 @@ export const tables = [ 'state', 'authrequests', 'ratelimits', + 'handle', + 'handle_transfer', ]; diff --git a/packages/api-main/src/gets/account.ts b/packages/api-main/src/gets/account.ts new file mode 100644 index 00000000..5139d703 --- /dev/null +++ b/packages/api-main/src/gets/account.ts @@ -0,0 +1,36 @@ +import type { Gets } from '@atomone/dither-api-types'; + +import { eq, or, sql } from 'drizzle-orm'; + +import { getDatabase } from '../../drizzle/db'; +import { AccountTable } from '../../drizzle/schema'; + +const statement = getDatabase() + .select() + .from(AccountTable) + .where( + or( + eq(AccountTable.handle, sql.placeholder('handle')), + eq(AccountTable.address, sql.placeholder('address')), + ), + ) + .prepare('stmnt_get_handle'); + +export async function Account(query: Gets.AccountQuery) { + const { address, handle } = query; + if (!address && !handle) { + return { status: 400, error: 'handle or address is required' }; + } + + try { + const [account] = await statement.execute({ address, handle }); + if (!account) { + return { status: 404, rows: [] }; + } + + return { status: 200, rows: [account] }; + } catch (error) { + console.error(error); + return { error: 'failed to read data from database' }; + } +} diff --git a/packages/api-main/src/gets/feed.ts b/packages/api-main/src/gets/feed.ts index 81f59779..f842f8b2 100644 --- a/packages/api-main/src/gets/feed.ts +++ b/packages/api-main/src/gets/feed.ts @@ -1,13 +1,18 @@ import type { Gets } from '@atomone/dither-api-types'; -import { and, count, desc, gte, isNull, sql } from 'drizzle-orm'; +import { and, count, desc, eq, getTableColumns, gte, isNull, sql } from 'drizzle-orm'; import { getDatabase } from '../../drizzle/db'; -import { FeedTable } from '../../drizzle/schema'; +import { AccountTable, FeedTable } from '../../drizzle/schema'; const statement = getDatabase() - .select() + .select({ + ...getTableColumns(FeedTable), + author_handle: AccountTable.handle, + author_display: AccountTable.display, + }) .from(FeedTable) + .leftJoin(AccountTable, eq(FeedTable.author, AccountTable.address)) .limit(sql.placeholder('limit')) .offset(sql.placeholder('offset')) .where( diff --git a/packages/api-main/src/gets/followers.ts b/packages/api-main/src/gets/followers.ts index bf85b9ac..15fe6dce 100644 --- a/packages/api-main/src/gets/followers.ts +++ b/packages/api-main/src/gets/followers.ts @@ -3,11 +3,17 @@ import type { Gets } from '@atomone/dither-api-types'; import { and, desc, eq, isNull, sql } from 'drizzle-orm'; import { getDatabase } from '../../drizzle/db'; -import { FollowsTable } from '../../drizzle/schema'; +import { AccountTable, FollowsTable } from '../../drizzle/schema'; const statementGetFollowers = getDatabase() - .select({ address: FollowsTable.follower, hash: FollowsTable.hash }) + .select({ + address: FollowsTable.follower, + handle: AccountTable.handle, + display: AccountTable.display, + hash: FollowsTable.hash, + }) .from(FollowsTable) + .leftJoin(AccountTable, eq(AccountTable.address, FollowsTable.follower)) .where(and(eq(FollowsTable.following, sql.placeholder('following')), isNull(FollowsTable.removed_at))) .limit(sql.placeholder('limit')) .offset(sql.placeholder('offset')) diff --git a/packages/api-main/src/gets/following.ts b/packages/api-main/src/gets/following.ts index 22d7da52..598fc4c0 100644 --- a/packages/api-main/src/gets/following.ts +++ b/packages/api-main/src/gets/following.ts @@ -3,11 +3,17 @@ import type { Gets } from '@atomone/dither-api-types'; import { and, desc, eq, isNull, sql } from 'drizzle-orm'; import { getDatabase } from '../../drizzle/db'; -import { FollowsTable } from '../../drizzle/schema'; +import { AccountTable, FollowsTable } from '../../drizzle/schema'; const statementGetFollowing = getDatabase() - .select({ address: FollowsTable.following, hash: FollowsTable.hash }) + .select({ + address: FollowsTable.following, + handle: AccountTable.handle, + display: AccountTable.display, + hash: FollowsTable.hash, + }) .from(FollowsTable) + .leftJoin(AccountTable, eq(AccountTable.address, FollowsTable.following)) .where(and(eq(FollowsTable.follower, sql.placeholder('follower')), isNull(FollowsTable.removed_at))) .limit(sql.placeholder('limit')) .offset(sql.placeholder('offset')) diff --git a/packages/api-main/src/gets/index.ts b/packages/api-main/src/gets/index.ts index 5425689a..f1233250 100644 --- a/packages/api-main/src/gets/index.ts +++ b/packages/api-main/src/gets/index.ts @@ -1,3 +1,4 @@ +export * from './account'; export * from './authVerify'; export * from './dislikes'; export * from './feed'; diff --git a/packages/api-main/src/gets/post.ts b/packages/api-main/src/gets/post.ts index 08a2fad7..9485f0da 100644 --- a/packages/api-main/src/gets/post.ts +++ b/packages/api-main/src/gets/post.ts @@ -1,19 +1,29 @@ import type { Gets } from '@atomone/dither-api-types'; -import { and, eq, isNull, sql } from 'drizzle-orm'; +import { and, eq, getTableColumns, isNull, sql } from 'drizzle-orm'; import { getDatabase } from '../../drizzle/db'; -import { FeedTable } from '../../drizzle/schema'; +import { AccountTable, FeedTable } from '../../drizzle/schema'; const statementGetPost = getDatabase() - .select() + .select({ + ...getTableColumns(FeedTable), + author_handle: AccountTable.handle, + author_display: AccountTable.display, + }) .from(FeedTable) + .leftJoin(AccountTable, eq(FeedTable.author, AccountTable.address)) .where(and(isNull(FeedTable.removed_at), eq(FeedTable.hash, sql.placeholder('hash')))) .prepare('stmnt_get_post'); const statementGetReply = getDatabase() - .select() + .select({ + ...getTableColumns(FeedTable), + author_handle: AccountTable.handle, + author_display: AccountTable.display, + }) .from(FeedTable) + .leftJoin(AccountTable, eq(FeedTable.author, AccountTable.address)) .where(and(isNull(FeedTable.removed_at), eq(FeedTable.hash, sql.placeholder('hash')), eq(FeedTable.post_hash, sql.placeholder('post_hash')))) .prepare('stmnt_get_reply'); diff --git a/packages/api-main/src/gets/posts.ts b/packages/api-main/src/gets/posts.ts index 9bccbc5e..0638ed0b 100644 --- a/packages/api-main/src/gets/posts.ts +++ b/packages/api-main/src/gets/posts.ts @@ -1,13 +1,18 @@ import type { Gets } from '@atomone/dither-api-types'; -import { and, desc, eq, gte, inArray, isNull, sql } from 'drizzle-orm'; +import { and, desc, eq, getTableColumns, gte, inArray, isNull, sql } from 'drizzle-orm'; import { getDatabase } from '../../drizzle/db'; -import { FeedTable, FollowsTable } from '../../drizzle/schema'; +import { AccountTable, FeedTable, FollowsTable } from '../../drizzle/schema'; const statement = getDatabase() - .select() + .select({ + ...getTableColumns(FeedTable), + author_handle: AccountTable.handle, + author_display: AccountTable.display, + }) .from(FeedTable) + .leftJoin(AccountTable, eq(FeedTable.author, AccountTable.address)) .where( and( eq(FeedTable.author, sql.placeholder('author')), @@ -48,8 +53,13 @@ export async function Posts(query: Gets.PostsQuery) { } const followingPostsStatement = getDatabase() - .select() + .select({ + ...getTableColumns(FeedTable), + author_handle: AccountTable.handle, + author_display: AccountTable.display, + }) .from(FeedTable) + .leftJoin(AccountTable, eq(FeedTable.author, AccountTable.address)) .where( and( inArray(FeedTable.author, getDatabase() diff --git a/packages/api-main/src/gets/search.ts b/packages/api-main/src/gets/search.ts index 09a32281..d077c9b7 100644 --- a/packages/api-main/src/gets/search.ts +++ b/packages/api-main/src/gets/search.ts @@ -1,9 +1,9 @@ import type { Gets } from '@atomone/dither-api-types'; -import { and, desc, gte, ilike, inArray, isNull, or, sql } from 'drizzle-orm'; +import { and, desc, eq, getTableColumns, gte, ilike, inArray, isNull, or, sql } from 'drizzle-orm'; import { getDatabase } from '../../drizzle/db'; -import { FeedTable } from '../../drizzle/schema'; +import { AccountTable, FeedTable } from '../../drizzle/schema'; export async function Search(query: Gets.SearchQuery) { try { @@ -22,12 +22,26 @@ export async function Search(query: Gets.SearchQuery) { const matchedAuthors = await getDatabase() .selectDistinct({ author: FeedTable.author }) .from(FeedTable) - .where(and(ilike(FeedTable.author, `%${query.text}%`), isNull(FeedTable.removed_at))); + .leftJoin(AccountTable, eq(FeedTable.author, AccountTable.address)) + .where( + and( + or( + eq(FeedTable.author, query.text.toLowerCase()), // Exact address + ilike(AccountTable.handle, `%${query.text}%`), // Registered handle (partial match) + ), + isNull(FeedTable.removed_at), + ), + ); const matchedAuthorAddresses = matchedAuthors.map(a => a.author); const matchedPosts = await getDatabase() - .select() + .select({ + ...getTableColumns(FeedTable), + author_handle: AccountTable.handle, + author_display: AccountTable.display, + }) .from(FeedTable) + .leftJoin(AccountTable, eq(FeedTable.author, AccountTable.address)) .where( and( or( @@ -40,8 +54,8 @@ export async function Search(query: Gets.SearchQuery) { ) .limit(100) .offset(0) - .orderBy(desc(FeedTable.timestamp)) - .execute(); + .orderBy(desc(FeedTable.timestamp)); + return { status: 200, rows: [...matchedPosts], users: matchedAuthorAddresses }; } catch (error) { console.error(error); diff --git a/packages/api-main/src/posts/acceptHandle.ts b/packages/api-main/src/posts/acceptHandle.ts new file mode 100644 index 00000000..9acb438e --- /dev/null +++ b/packages/api-main/src/posts/acceptHandle.ts @@ -0,0 +1,74 @@ +import type { Posts } from '@atomone/dither-api-types'; + +import { and, count, eq, sql } from 'drizzle-orm'; + +import { getDatabase } from '../../drizzle/db'; +import { AccountTable, HandleTransferTable } from '../../drizzle/schema'; +import { lower } from '../utility'; + +const transferToAddressExistsStmt = getDatabase() + .select({ count: count() }) + .from(HandleTransferTable) + .where( + and( + eq(lower(HandleTransferTable.name), sql.placeholder('handle')), + eq(HandleTransferTable.to_address, sql.placeholder('address')), + eq(HandleTransferTable.accepted, false), + ), + ) + .prepare('stmt_transfer_to_adress_exists'); + +export async function AcceptHandle(body: Posts.AcceptHandleBody) { + const address = body.address.toLowerCase(); + const handle = body.handle.toLowerCase(); + + try { + if (!await doesTransferToAddressExists(handle, address)) { + return { status: 400, error: 'handle not found or not transferred to address' }; + } + + await getDatabase().transaction(async (tx) => { + // Remove handle from current owner + await tx + .update(AccountTable) + .set({ handle: null }) + .where(eq(lower(AccountTable.handle), handle)); + + // Assign handle to new owner + await tx + .insert(AccountTable) + .values({ + address, + handle: body.handle, + }) + .onConflictDoUpdate({ + target: AccountTable.address, + set: { + handle: body.handle, + }, + }); + + // Mark transfer(s) as finished to disallow potential future usage of this transfer + await tx + .update(HandleTransferTable) + .set({ accepted: true }) + .where( + and( + eq(lower(HandleTransferTable.name), handle), + eq(HandleTransferTable.to_address, address), + eq(HandleTransferTable.accepted, false), + ), + ); + }); + + return { status: 200 }; + } catch (err) { + console.error(err); + return { status: 400, error: 'failed to transfer handle' }; + } +} + +async function doesTransferToAddressExists(handle: string, address: string): Promise { + const [result] = await transferToAddressExistsStmt.execute({ address, handle }); + return (result?.count ?? 0) !== 0; +} diff --git a/packages/api-main/src/posts/displayHandle.ts b/packages/api-main/src/posts/displayHandle.ts new file mode 100644 index 00000000..8fe846f5 --- /dev/null +++ b/packages/api-main/src/posts/displayHandle.ts @@ -0,0 +1,31 @@ +import type { Posts } from '@atomone/dither-api-types'; + +import { getDatabase } from '../../drizzle/db'; +import { AccountTable } from '../../drizzle/schema'; + +const MAX_DISPLAY_LENGTH = 128; + +export async function DisplayHandle(body: Posts.DisplayHandleBody) { + const display = (body.display || '').trim(); + if (display.length > MAX_DISPLAY_LENGTH) { + return { status: 400, error: `maximum display length is ${MAX_DISPLAY_LENGTH} characters long` }; + } + + try { + await getDatabase() + .insert(AccountTable) + .values({ + address: body.address.toLowerCase(), + display, + }) + .onConflictDoUpdate({ + target: AccountTable.address, + set: { display }, + }); + + return { status: 200 }; + } catch (err) { + console.error(err); + return { status: 400, error: 'failed to update display handle' }; + } +} diff --git a/packages/api-main/src/posts/index.ts b/packages/api-main/src/posts/index.ts index d7030b77..8a6bc37b 100644 --- a/packages/api-main/src/posts/index.ts +++ b/packages/api-main/src/posts/index.ts @@ -1,6 +1,8 @@ +export * from './acceptHandle'; export * from './auth'; export * from './authCreate'; export * from './dislike'; +export * from './displayHandle'; export * from './flag'; export * from './follow'; export * from './like'; @@ -8,6 +10,8 @@ export * from './logout'; export * from './mod'; export * from './post'; export * from './postRemove'; +export * from './registerHandle'; export * from './reply'; +export * from './transferHandle'; export * from './unfollow'; export * from './updateState'; diff --git a/packages/api-main/src/posts/registerHandle.ts b/packages/api-main/src/posts/registerHandle.ts new file mode 100644 index 00000000..2b2c54c7 --- /dev/null +++ b/packages/api-main/src/posts/registerHandle.ts @@ -0,0 +1,60 @@ +import type { Posts } from '@atomone/dither-api-types'; + +import type { DbClient } from '../../drizzle/db'; + +import { eq } from 'drizzle-orm'; + +import { getDatabase } from '../../drizzle/db'; +import { AccountTable } from '../../drizzle/schema'; +import { notify } from '../shared/notify'; +import { lower } from '../utility'; +import { checkAccountHandleIsValid } from '../utility/handle'; + +export async function RegisterHandle(body: Posts.RegisterHandleBody) { + try { + checkAccountHandleIsValid(body.handle); + } catch (e) { + return { status: 400, error: (e as Error).message }; + } + + const db = getDatabase(); + const address = body.address.toLowerCase(); + + try { + if (await doesHandleExists(db, body.handle)) { + await notify({ + hash: body.hash, + type: 'registerHandle', + actor: address, + owner: address, + timestamp: new Date(body.timestamp), + subcontext: `Handle @${body.handle} is already taken`, + }); + + return { status: 400, error: 'handle is already registered' }; + } + + await db + .insert(AccountTable) + .values({ + address, + handle: body.handle, + }) + .onConflictDoUpdate({ + target: AccountTable.address, + set: { + handle: body.handle, + }, + }); + + return { status: 200 }; + } catch (err) { + console.error(err); + return { status: 400, error: 'failed to register handle' }; + } +} + +async function doesHandleExists(db: DbClient, name: string): Promise { + const count = await db.$count(AccountTable, eq(lower(AccountTable.handle), name.toLowerCase())); + return count !== 0; +} diff --git a/packages/api-main/src/posts/transferHandle.ts b/packages/api-main/src/posts/transferHandle.ts new file mode 100644 index 00000000..beecb1ed --- /dev/null +++ b/packages/api-main/src/posts/transferHandle.ts @@ -0,0 +1,63 @@ +import type { Posts } from '@atomone/dither-api-types'; + +import { and, count, eq, sql } from 'drizzle-orm'; + +import { getDatabase } from '../../drizzle/db'; +import { AccountTable, HandleTransferTable } from '../../drizzle/schema'; +import { notify } from '../shared/notify'; +import { lower } from '../utility'; + +const handleOwnerExistsStmt = getDatabase() + .select({ count: count() }) + .from(AccountTable) + .where( + and( + eq(AccountTable.address, sql.placeholder('address')), + eq(lower(AccountTable.handle), sql.placeholder('handle')), + ), + ) + .prepare('stmt_handle_owner_exists'); + +export async function TransferHandle(body: Posts.TransferHandleBody) { + const fromAddress = body.from_address.toLowerCase(); + const toAddress = body.to_address.toLowerCase(); + const timestamp = new Date(body.timestamp); + + try { + if (!await isHandleOwner(fromAddress, body.handle)) { + return { status: 400, error: 'handle not found or not registered to sender address' }; + } + + await getDatabase() + .insert(HandleTransferTable) + .values({ + hash: body.hash.toLowerCase(), + name: body.handle, + from_address: fromAddress, + to_address: body.to_address.toLowerCase(), + timestamp, + }); + + await notify({ + hash: body.hash, + type: 'transferHandle', + actor: fromAddress, + owner: toAddress, + subcontext: `You received @${body.handle} handle`, + timestamp, + }); + + return { status: 200 }; + } catch (err) { + console.error(err); + return { status: 400, error: 'failed to transfer handle' }; + } +} + +async function isHandleOwner(address: string, handle: string): Promise { + const [result] = await handleOwnerExistsStmt.execute({ + address, + handle: handle.toLowerCase(), + }); + return (result?.count ?? 0) !== 0; +} diff --git a/packages/api-main/src/routes/public.ts b/packages/api-main/src/routes/public.ts index 40317c7e..818debe6 100644 --- a/packages/api-main/src/routes/public.ts +++ b/packages/api-main/src/routes/public.ts @@ -21,4 +21,5 @@ export const publicRoutes = new Elysia() .get('/search', ({ query }) => GetRequests.Search(query), { query: Gets.SearchQuerySchema }) .get('/user-replies', ({ query }) => GetRequests.UserReplies(query), { query: Gets.UserRepliesQuerySchema }) .get('/following-posts', ({ query }) => GetRequests.FollowingPosts(query), { query: Gets.PostsQuerySchema }) + .get('/account', ({ query }) => GetRequests.Account(query), { query: Gets.AccountQuerySchema }) .get('/last-block', GetRequests.LastBlock); diff --git a/packages/api-main/src/routes/reader.ts b/packages/api-main/src/routes/reader.ts index f8e1d973..e9fcdb4b 100644 --- a/packages/api-main/src/routes/reader.ts +++ b/packages/api-main/src/routes/reader.ts @@ -18,6 +18,10 @@ export const readerRoutes = new Elysia() .post('/dislike', ({ body }) => PostRequests.Dislike(body), { body: Posts.DislikeBodySchema }) .post('/flag', ({ body }) => PostRequests.Flag(body), { body: Posts.FlagBodySchema }) .post('/post-remove', ({ body }) => PostRequests.PostRemove(body), { body: Posts.PostRemoveBodySchema }) + .post('/register-handle', ({ body }) => PostRequests.RegisterHandle(body), { body: Posts.RegisterHandleBodySchema }) + .post('/transfer-handle', ({ body }) => PostRequests.TransferHandle(body), { body: Posts.TransferHandleBodySchema }) + .post('/accept-handle', ({ body }) => PostRequests.AcceptHandle(body), { body: Posts.AcceptHandleBodySchema }) + .post('/display-handle', ({ body }) => PostRequests.DisplayHandle(body), { body: Posts.DisplayHandleBodySchema }) .post('/update-state', ({ body }) => PostRequests.UpdateState(body), { body: t.Object({ last_block: t.String() }), }); diff --git a/packages/api-main/src/shared/notify.ts b/packages/api-main/src/shared/notify.ts index 1899c5d0..f0ae45d4 100644 --- a/packages/api-main/src/shared/notify.ts +++ b/packages/api-main/src/shared/notify.ts @@ -37,6 +37,10 @@ export async function notify(data: { } owner ??= post.author; + if (owner === data.actor) { + return; + } + if (!data.subcontext) { const subcontext = post.message.length >= 64 ? `${post.message.slice(0, 61)}...` : post.message; data.subcontext = subcontext; @@ -47,10 +51,6 @@ export async function notify(data: { throw new Error('failed to add owner'); } - if (owner === data.actor) { - return; - } - await statementInsertNotification.execute({ owner: owner.toLowerCase(), post_hash: data.post_hash?.toLowerCase(), diff --git a/packages/api-main/src/types/account.ts b/packages/api-main/src/types/account.ts new file mode 100644 index 00000000..ae7e35bc --- /dev/null +++ b/packages/api-main/src/types/account.ts @@ -0,0 +1,5 @@ +import type { InferSelectModel } from 'drizzle-orm'; + +import type { AccountTable } from '../../drizzle/schema'; + +export type Account = InferSelectModel; diff --git a/packages/api-main/src/types/feed.ts b/packages/api-main/src/types/feed.ts index 6e7e995c..65766995 100644 --- a/packages/api-main/src/types/feed.ts +++ b/packages/api-main/src/types/feed.ts @@ -5,7 +5,10 @@ import { createSelectSchema } from 'drizzle-typebox'; import { FeedTable } from '../../drizzle/schema'; -export type Post = InferSelectModel; +export type Post = InferSelectModel & { + author_handle?: string; + author_display?: string; +}; export const postSchema = createSelectSchema(FeedTable); export interface ReplyWithParent { diff --git a/packages/api-main/src/types/follows.ts b/packages/api-main/src/types/follows.ts index 7027833d..ef8af5d9 100644 --- a/packages/api-main/src/types/follows.ts +++ b/packages/api-main/src/types/follows.ts @@ -11,4 +11,7 @@ export const followingSchema = Type.Object({ address: followSchema.properties.following, hash: followSchema.properties.hash, }); -export type Following = Static; +export type Following = Static & { + display?: string; + handle?: string; +}; diff --git a/packages/api-main/src/utility/handle.ts b/packages/api-main/src/utility/handle.ts new file mode 100644 index 00000000..bc7d4d87 --- /dev/null +++ b/packages/api-main/src/utility/handle.ts @@ -0,0 +1,13 @@ +export const MIN_HANDLE_LENGTH = 5; +export const MAX_HANDLE_LENGTH = 25; +export const HANDLE_REGEX = /^[a-z]{3}\w*$/i; // Note: Length is not validated by this regex + +export function checkAccountHandleIsValid(handle: string) { + if (!HANDLE_REGEX.test(handle)) { + throw new Error('Handle must start with three letters and can only include letters, numbers and underscores'); + } + + if (handle.length < MIN_HANDLE_LENGTH || handle.length > MAX_HANDLE_LENGTH) { + throw new Error(`Handle must have between ${MIN_HANDLE_LENGTH} and ${MAX_HANDLE_LENGTH} characters long`); + } +} diff --git a/packages/api-main/src/utility/index.ts b/packages/api-main/src/utility/index.ts index 963b5570..9b87bc78 100644 --- a/packages/api-main/src/utility/index.ts +++ b/packages/api-main/src/utility/index.ts @@ -1,3 +1,6 @@ +import type { SQL } from 'drizzle-orm'; +import type { AnyPgColumn } from 'drizzle-orm/pg-core'; + import type * as T from '../types/index'; import { Buffer } from 'node:buffer'; @@ -10,6 +13,10 @@ import { useConfig } from '../config'; const { AUTH, DISCORD_WEBHOOK_URL } = useConfig(); +export function lower(email: AnyPgColumn): SQL { + return sql`lower(${email})`; +} + export function getTransferMessage(messages: Array) { const msgTransfer = messages.find(msg => msg['@type'] === '/cosmos.bank.v1beta1.MsgSend'); if (!msgTransfer) { diff --git a/packages/frontend-main/.env.example b/packages/frontend-main/.env.example index 9f0e09a8..bdc95120 100644 --- a/packages/frontend-main/.env.example +++ b/packages/frontend-main/.env.example @@ -1,5 +1,7 @@ VITE_ENVIRONMENT_TYPE=testnet +VITE_MIN_REGISTER_HANDLE_FEE=0.000001 + VITE_API_ROOT_DEVNET=https://dither-staging.stuyk.com/v1 VITE_EXPLORER_URL_DEVNET=https://testnet.explorer.allinbits.services/atomone-devnet-1/tx VITE_COMMUNITY_WALLET_DEVNET=atone1uq6zjslvsa29cy6uu75y8txnl52mw06j6fzlep @@ -13,4 +15,4 @@ VITE_AUTHZ_GRANTEE_TESTNET=atone1uq6zjslvsa29cy6uu75y8txnl52mw06j6fzlep VITE_API_ROOT_MAINNET=https://api.mainnet.dither.chat/v1 VITE_EXPLORER_URL_MAINNET=https://www.mintscan.io/atomone/tx VITE_COMMUNITY_WALLET_MAINNET=atone1uq6zjslvsa29cy6uu75y8txnl52mw06j6fzlep -VITE_AUTHZ_GRANTEE_MAINNET=atone1uq6zjslvsa29cy6uu75y8txnl52mw06j6fzlep \ No newline at end of file +VITE_AUTHZ_GRANTEE_MAINNET=atone1uq6zjslvsa29cy6uu75y8txnl52mw06j6fzlep diff --git a/packages/frontend-main/ARCHITECTURE.md b/packages/frontend-main/ARCHITECTURE.md index 7508d88d..dba06bcc 100644 --- a/packages/frontend-main/ARCHITECTURE.md +++ b/packages/frontend-main/ARCHITECTURE.md @@ -193,6 +193,14 @@ dither.Flag('0xghi789...', 'spam'); // User management dither.Unfollow('cosmos1user...'); dither.Dislike('0xjkl012...'); + +// Username registration and transfer to a new address +dither.RegisterHandle('handle'); +dither.TransferHandle('handle', '0xabc123...'); +dither.AcceptHandle('handle'); + +// Modify user display text +dither.SetDisplayHandle('display text'); ``` ### Supported Wallets @@ -212,3 +220,4 @@ dither.Dislike('0xjkl012...'); - **Reply threading** with nested conversations - **User tipping** and social interactions - **Content moderation** through flagging system +- **Usernames and display text** by registering transferable handles diff --git a/packages/frontend-main/public/csp.json b/packages/frontend-main/public/csp.json index 98044a80..bedd6c64 100644 --- a/packages/frontend-main/public/csp.json +++ b/packages/frontend-main/public/csp.json @@ -26,6 +26,7 @@ "https://cloud.umami.is", "https://api-gateway.umami.dev", "https://*.dither.chat", - "https://*.allinbits.com" + "https://*.allinbits.com", + "https://*.allinbits.services" ] } diff --git a/packages/frontend-main/src/App.vue b/packages/frontend-main/src/App.vue index 871b2ede..5f2fae75 100644 --- a/packages/frontend-main/src/App.vue +++ b/packages/frontend-main/src/App.vue @@ -8,6 +8,7 @@ import FollowDialog from './components/popups/FollowUserDialog.vue'; import InvalidDefaultAmountDialog from './components/popups/InvalidDefaultAmountDialog.vue'; import LikePostDialog from './components/popups/LikePostDialog.vue'; import NewPostDialog from './components/popups/NewPostDialog.vue'; +import RegisterHandleDialog from './components/popups/RegisterHandleDialog.vue'; import ReplyDialog from './components/popups/ReplyDialog.vue'; import TipUserDialog from './components/popups/TipUserDialog.vue'; import UnfollowDialog from './components/popups/UnfollowUserDialog.vue'; @@ -43,6 +44,7 @@ onMounted(() => { + diff --git a/packages/frontend-main/src/components/notifications/NotificationType.vue b/packages/frontend-main/src/components/notifications/NotificationType.vue index 04b9de3e..afa3266f 100644 --- a/packages/frontend-main/src/components/notifications/NotificationType.vue +++ b/packages/frontend-main/src/components/notifications/NotificationType.vue @@ -1,7 +1,7 @@ + + diff --git a/packages/frontend-main/src/components/notifications/TransferHandleNotification.vue b/packages/frontend-main/src/components/notifications/TransferHandleNotification.vue new file mode 100644 index 00000000..94b045b0 --- /dev/null +++ b/packages/frontend-main/src/components/notifications/TransferHandleNotification.vue @@ -0,0 +1,14 @@ + + + diff --git a/packages/frontend-main/src/components/popups/FlagPostDialog.vue b/packages/frontend-main/src/components/popups/FlagPostDialog.vue index 465433e6..199334f1 100644 --- a/packages/frontend-main/src/components/popups/FlagPostDialog.vue +++ b/packages/frontend-main/src/components/popups/FlagPostDialog.vue @@ -64,7 +64,7 @@ async function handleSubmit() {
- +
diff --git a/packages/frontend-main/src/components/popups/RegisterHandleDialog.vue b/packages/frontend-main/src/components/popups/RegisterHandleDialog.vue new file mode 100644 index 00000000..62941dc2 --- /dev/null +++ b/packages/frontend-main/src/components/popups/RegisterHandleDialog.vue @@ -0,0 +1,65 @@ + + + diff --git a/packages/frontend-main/src/components/popups/ReplyDialog.vue b/packages/frontend-main/src/components/popups/ReplyDialog.vue index 45fe967f..d89f3b91 100644 --- a/packages/frontend-main/src/components/popups/ReplyDialog.vue +++ b/packages/frontend-main/src/components/popups/ReplyDialog.vue @@ -66,7 +66,7 @@ function handleInputValidity(value: boolean) {
- +
diff --git a/packages/frontend-main/src/components/posts/PostItem.vue b/packages/frontend-main/src/components/posts/PostItem.vue index b51d17ee..094ae2d7 100644 --- a/packages/frontend-main/src/components/posts/PostItem.vue +++ b/packages/frontend-main/src/components/posts/PostItem.vue @@ -40,7 +40,7 @@ const usedPost = computed(() => cachedPost.value || props.post);
- +
diff --git a/packages/frontend-main/src/components/ui/search/SearchInput.vue b/packages/frontend-main/src/components/ui/search/SearchInput.vue index 168ad182..03a4b127 100644 --- a/packages/frontend-main/src/components/ui/search/SearchInput.vue +++ b/packages/frontend-main/src/components/ui/search/SearchInput.vue @@ -57,7 +57,7 @@ function clearSearch() { @click="clearSearch" > - +
diff --git a/packages/frontend-main/src/components/users/UserAvatarUsername.vue b/packages/frontend-main/src/components/users/UserAvatarUsername.vue index 5e50e795..c7134f91 100644 --- a/packages/frontend-main/src/components/users/UserAvatarUsername.vue +++ b/packages/frontend-main/src/components/users/UserAvatarUsername.vue @@ -6,6 +6,7 @@ import Username from './Username.vue'; export interface UserAvatarUsernameProps { userAddress?: string; + userHandle?: string | null; size?: 'lg' | 'md' | 'sm'; disabled?: boolean; } diff --git a/packages/frontend-main/src/components/users/Username.vue b/packages/frontend-main/src/components/users/Username.vue index 18cc59d2..74dbe1ba 100644 --- a/packages/frontend-main/src/components/users/Username.vue +++ b/packages/frontend-main/src/components/users/Username.vue @@ -1,12 +1,23 @@ diff --git a/packages/frontend-main/src/components/wallet/WalletConnectButton/WalletConnectButton.vue b/packages/frontend-main/src/components/wallet/WalletConnectButton/WalletConnectButton.vue index 99657e7d..cd90e6ae 100644 --- a/packages/frontend-main/src/components/wallet/WalletConnectButton/WalletConnectButton.vue +++ b/packages/frontend-main/src/components/wallet/WalletConnectButton/WalletConnectButton.vue @@ -7,6 +7,7 @@ import { PopoverTrigger, } from '@/components/ui/popover'; import UserAvatarUsername from '@/components/users/UserAvatarUsername.vue'; +import { useAccount } from '@/composables/useAccount'; import { useWallet } from '@/composables/useWallet'; import { useWalletDialogStore } from '@/stores/useWalletDialogStore'; @@ -16,6 +17,7 @@ const isConnecting = ref(false); const isError = ref(false); const { address, loggedIn } = useWallet(); +const { data: account } = useAccount({ address }); const walletDialogStore = useWalletDialogStore(); const connectedState = computed(() => !isConnecting.value && loggedIn.value && !isError.value); @@ -27,9 +29,8 @@ const connectedState = computed(() => !isConnecting.value && loggedIn.value && ! diff --git a/packages/frontend-main/src/composables/useAccount.ts b/packages/frontend-main/src/composables/useAccount.ts new file mode 100644 index 00000000..7bef6e61 --- /dev/null +++ b/packages/frontend-main/src/composables/useAccount.ts @@ -0,0 +1,42 @@ +import type { Account } from 'api-main/types/account'; +import type { Ref } from 'vue'; + +import { queryOptions, useQuery } from '@tanstack/vue-query'; + +import { useConfigStore } from '@/stores/useConfigStore'; + +interface Params { + address: Ref; +} + +export function account(params: Params) { + const configStore = useConfigStore(); + const apiRoot = configStore.envConfig.apiRoot ?? 'http://localhost:3000/v1'; + + return queryOptions({ + queryKey: ['account', params.address], + queryFn: async () => { + const url = `${apiRoot}/account?address=${params.address.value}`; + + const response = await fetch(url); + if (!response.ok) { + const errorData = await response.json().catch(() => undefined); + throw new Error(errorData?.error || 'Failed to fetch account'); + } + + const result = await response.json(); + if (result.status === 404 || !result.rows?.length) { + console.warn(`Account ${params.address.value} not found`); + return null; + } + + return (result.rows[0] as Account); + }, + enabled: () => !!params.address.value, + staleTime: Infinity, + }); +} + +export function useAccount(params: Params) { + return useQuery(account(params)); +} diff --git a/packages/frontend-main/src/composables/useAvailableHandleChecker.ts b/packages/frontend-main/src/composables/useAvailableHandleChecker.ts new file mode 100644 index 00000000..e4bbf16f --- /dev/null +++ b/packages/frontend-main/src/composables/useAvailableHandleChecker.ts @@ -0,0 +1,21 @@ +import { useConfigStore } from '@/stores/useConfigStore'; + +export function useAvailableHandleChecker() { + const configStore = useConfigStore(); + const apiRoot = configStore.envConfig.apiRoot ?? 'http://localhost:3000/v1'; + + return async (handle: string): Promise => { + if (!handle?.trim()) { + return false; + } + + const response = await fetch(`${apiRoot}/account?handle=${handle}`); + if (!response.ok) { + const errorData = await response.json().catch(() => undefined); + throw new Error(errorData?.error || 'Failed to fetch account'); + } + + const result = await response.json(); + return (result.status === 404 || !result.rows?.length); + }; +} diff --git a/packages/frontend-main/src/composables/usePopups.ts b/packages/frontend-main/src/composables/usePopups.ts index 8a4f67af..77252ab3 100644 --- a/packages/frontend-main/src/composables/usePopups.ts +++ b/packages/frontend-main/src/composables/usePopups.ts @@ -11,6 +11,7 @@ export interface PopupState { follow: string | null; unfollow: string | null; tipUser: string | null; + registerHandle: string | null; invalidDefaultAmount: string | null; } @@ -23,6 +24,7 @@ const state = reactive({ follow: null, unfollow: null, tipUser: null, + registerHandle: null, invalidDefaultAmount: null, }); diff --git a/packages/frontend-main/src/composables/useRegisterHandle.ts b/packages/frontend-main/src/composables/useRegisterHandle.ts new file mode 100644 index 00000000..744e4e7e --- /dev/null +++ b/packages/frontend-main/src/composables/useRegisterHandle.ts @@ -0,0 +1,67 @@ +import { Decimal } from '@cosmjs/math'; +import { useMutation } from '@tanstack/vue-query'; +import { ref } from 'vue'; + +import { useConfigStore } from '@/stores/useConfigStore'; +import { fractionalDigits } from '@/utility/atomics'; +import { showInfoToast } from '@/utility/toast'; + +import { useTxNotification } from './useTxNotification'; +import { useWallet } from './useWallet'; + +interface RegisterHandleRequestMutation { + handle: string; + amountAtomics: string; +} + +export function useRegisterHandle() { + const configStore = useConfigStore(); + const wallet = useWallet(); + const txError = ref(); + const txSuccess = ref(); + const isToastShown = ref(false); + useTxNotification('Register Handle', txSuccess, txError); + + const minAmount = Decimal.fromAtomics(configStore.config.minRegisterHandleFee, fractionalDigits); + + const { + mutateAsync, + } = useMutation({ + mutationFn: async ({ handle, amountAtomics }: RegisterHandleRequestMutation) => { + txError.value = undefined; + txSuccess.value = undefined; + isToastShown.value = true; + + const amount = Decimal.fromAtomics(amountAtomics, fractionalDigits); + if (amount.isLessThan(minAmount)) { + const msg = `Handle registration requires a minimum of ${minAmount} PHOTON`; + txError.value = msg; + throw new Error(msg); + } + + const result = await wallet.dither.send('RegisterHandle', { + args: [handle], + amount: amountAtomics, + }); + + if (!result.broadcast) { + txError.value = result.msg; + throw new Error(result.msg); + } + + txSuccess.value = result.tx?.transactionHash; + }, + onSuccess: () => { + showInfoToast('Account Handle Registered', 'Your new handle will take effect soon', 9000); + }, + onSettled: () => { + isToastShown.value = false; + }, + }); + + return { + registerHandle: mutateAsync, + txError, + txSuccess, + }; +} diff --git a/packages/frontend-main/src/localization/index.ts b/packages/frontend-main/src/localization/index.ts index 0e73076b..2ac12e71 100644 --- a/packages/frontend-main/src/localization/index.ts +++ b/packages/frontend-main/src/localization/index.ts @@ -68,6 +68,7 @@ export const messages = { follow: 'Follow User', unfollow: 'Unfollow User', tipUser: 'Tip User', + registerHandle: 'Register Handle', invalidDefaultAmount: 'Not enough balance', }, PopupDescriptions: { @@ -89,6 +90,7 @@ export const messages = { settings: 'Settings', manageFollows: 'Manage Following', envConfig: 'Environment Config', + account: 'Account', authz: 'Authz', post: 'Post', explore: 'Explore', @@ -117,6 +119,8 @@ export const messages = { flag: 'Redflagged your post', follow: 'Followed you', reply: 'Replied to your post', + registerHandle: 'User handle registration', + transferHandle: 'Sent you a user handle', empty: 'Nothing to show', }, FollowingList: { @@ -137,6 +141,9 @@ export const messages = { defaultAmount: 'Default Amount', following: 'Following', back: 'Back', + accountHandle: 'Account Handle', + accountHandleSummary: 'Accounts can have a linked handle name which is usually used instead of the account address.\nIf an account is linked to a handle and a new one is registered it makes the current one available to anyone.', + accountHandleRegister: 'Register', whatIsIt: 'What is it?', singleSessionSummary: 'Single Session creations a local private key through a passkey to sign transactions through the Comsos Authz and Feegrant modules. The settings below let you configure the authorization.', createSession: 'Create Session', diff --git a/packages/frontend-main/src/router.ts b/packages/frontend-main/src/router.ts index 793598ea..443b05c7 100644 --- a/packages/frontend-main/src/router.ts +++ b/packages/frontend-main/src/router.ts @@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'; import { useWalletStateStore } from './stores/useWalletStateStore'; import AboutView from './views/AboutView.vue'; +import AccountView from './views/AccountView.vue'; import EnvConfigView from './views/EnvConfigView.vue'; import HomeFeedView from './views/Home/HomeFeedView.vue'; import HomeFollowingView from './views/Home/HomeFollowingView.vue'; @@ -23,6 +24,7 @@ export const routesNames = { profile: 'Profile', profileReplies: 'Profile Replies', settings: 'Settings', + settingsAccount: 'Settings Account', settingsManageFollowers: 'Settings Manage Followers', settingsConfig: 'Settings Config', settingsSingleSession: 'Settings Single Session', @@ -40,6 +42,7 @@ const routes = [ { path: '/profile/:address', name: routesNames.profile, component: ProfilePostsView }, { path: '/profile/:address/replies', name: routesNames.profileReplies, component: ProfileRepliesView }, { path: '/settings', name: routesNames.settings, component: SettingsView, meta: { authRequired: true } }, + { path: '/settings/account', name: routesNames.settingsAccount, component: AccountView, meta: { authRequired: true } }, { path: '/settings/manage-following', name: routesNames.settingsManageFollowers, component: ManageFollowingView, meta: { authRequired: true } }, { path: '/settings/env-config', name: routesNames.settingsConfig, component: EnvConfigView, meta: { authRequired: true } }, { path: '/settings/settings-single-session', name: routesNames.settingsSingleSession, component: SettingsSingleSession, meta: { authRequired: true } }, diff --git a/packages/frontend-main/src/stores/useConfigStore.ts b/packages/frontend-main/src/stores/useConfigStore.ts index 72c74267..846050f2 100644 --- a/packages/frontend-main/src/stores/useConfigStore.ts +++ b/packages/frontend-main/src/stores/useConfigStore.ts @@ -10,6 +10,7 @@ interface Config { envConfigs: typeof envConfigs; defaultAmountAtomics: string; defaultAmountEnabled: boolean; + minRegisterHandleFee: string; } const defaultConfig: Config = { @@ -17,6 +18,7 @@ const defaultConfig: Config = { selectedChain: import.meta.env.VITE_ENVIRONMENT_TYPE ?? 'mainnet', defaultAmountAtomics: Decimal.fromUserInput('0.1', fractionalDigits).atomics, defaultAmountEnabled: false, + minRegisterHandleFee: Decimal.fromUserInput(import.meta.env.VITE_MIN_REGISTER_HANDLE_FEE ?? '1', fractionalDigits).atomics, }; // deep clone the default config to avoid mutating the original object diff --git a/packages/frontend-main/src/types/index.ts b/packages/frontend-main/src/types/index.ts index 88572f70..3f52cddd 100644 --- a/packages/frontend-main/src/types/index.ts +++ b/packages/frontend-main/src/types/index.ts @@ -15,4 +15,18 @@ export interface DitherTypes { Dislike: [string]; // PostHash Flag: [string]; + // Handle + RegisterHandle: [string]; }; + +export interface DisplayableAuthor { + author: string; + author_handle?: string; + author_display?: string; +} + +export interface DisplayableUser { + address: string; + handle?: string; + display?: string; +} diff --git a/packages/frontend-main/src/views/AccountView.vue b/packages/frontend-main/src/views/AccountView.vue new file mode 100644 index 00000000..0c040cc8 --- /dev/null +++ b/packages/frontend-main/src/views/AccountView.vue @@ -0,0 +1,116 @@ + + + diff --git a/packages/frontend-main/src/views/ManageFollowingView.vue b/packages/frontend-main/src/views/ManageFollowingView.vue index 2bbe57f5..7b5648ab 100644 --- a/packages/frontend-main/src/views/ManageFollowingView.vue +++ b/packages/frontend-main/src/views/ManageFollowingView.vue @@ -80,7 +80,7 @@ async function onClickUnfollow(address: string) { class="flex flex-row items-center justify-between p-4 border-b" > - +