diff --git a/.github/workflows/deploy-preview.yml b/.github/workflows/deploy-preview.yml index 69e030a074..5f298767ed 100644 --- a/.github/workflows/deploy-preview.yml +++ b/.github/workflows/deploy-preview.yml @@ -77,8 +77,10 @@ jobs: - name: Run Migrations if: needs.detect-changes.outputs.has_migrations == 'true' + env: + BRANCH_DB_URL: ${{ steps.create-branch.outputs.db_url_with_pooler }} run: | - echo "BRANCH_DB_URL: ${{ steps.create-branch.outputs.db_url_with_pooler }}" >> apps/web/.env + vercel env pull --environment=preview --token=${{ env.VERCEL_TOKEN }} pnpm run migrate - name: Post Schema Diff Comment to PR @@ -90,11 +92,17 @@ jobs: api_key: ${{ secrets.NEON_API_KEY }} - name: Build Project Artifacts - run: vercel build --token=${{ env.VERCEL_TOKEN }} + env: + BRANCH_DB_URL: ${{ steps.create-branch.outputs.db_url_with_pooler }} + run: | + vercel env pull --environment=preview --token=${{ env.VERCEL_TOKEN }} + vercel build --token=${{ env.VERCEL_TOKEN }} - name: Deploy Preview to Vercel id: deploy - run: echo preview_url=$(vercel deploy --prebuilt --token=${{ env.VERCEL_TOKEN }}) >> $GITHUB_OUTPUT + env: + BRANCH_DB_URL: ${{ steps.create-branch.outputs.db_url_with_pooler }} + run: echo preview_url=$(vercel deploy --prebuilt --token=${{ env.VERCEL_TOKEN }} --env BRANCH_DB_URL=${{ env.BRANCH_DB_URL }}) >> $GITHUB_OUTPUT main: needs: [detect-changes] diff --git a/apps/web/.env.template b/apps/web/.env.template index 0314a4b7ae..cc77003405 100644 --- a/apps/web/.env.template +++ b/apps/web/.env.template @@ -13,10 +13,14 @@ NEXT_PUBLIC_CONNECT_SOURCES="https://*.uploadthing.com, https://auth.privy.io" # Privy NEXT_PUBLIC_PRIVY_APP_ID=cm5y07p2z02napk1cutzzx7o6 +PRIVY_APP_SECRET= # Web3Auth NEXT_PUBLIC_WEB3AUTH_CLIENT_ID=BBKg7QjLogP_fTLATXrdCIiyTguXFTKJkCLYIr51c_-P0y7EyLBIABEHW6psI2qm919Zk8broitgD66hZ2EySKY +# Hypha AI panel (Google Gemini) +GOOGLE_GENERATIVE_AI_API_KEY= + # Alchemy ALCHEMY_API_KEY= @@ -58,3 +62,17 @@ DISABLE_IMAGE_OPTIMIZATION=false # SOCKS5 Proxy SOCKS5_PROXY_HOST= SOCKS5_PROXY_PORT= + +# Matrix +NEXT_PUBLIC_ELEMENT_URL=https://threads.hypha.earth +NEXT_PUBLIC_MATRIX_HOMESERVER_URL=https://matrix.hypha.earth +MATRIX_DOMAIN=matrix.hypha.earth +MATRIX_ADMIN_TOKEN= +DEFAULT_ROOM_ID= +MATRIX_REGISTRATION_SHARED_SECRET= +# 32-byte in hex format +MATRIX_PASSWORD_SECRET= + +# reCAPTCHA +RECAPTCHA_SITE_KEY= +RECAPTCHA_SECRET_KEY= diff --git a/apps/web/package.json b/apps/web/package.json index 79eaf42272..3e181405f0 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -12,6 +12,8 @@ "postinstall": "if [ ! -f .env ]; then cp .env.template .env; fi" }, "dependencies": { + "@ai-sdk/google": "^2.0.0", + "@ai-sdk/react": "^3.0.107", "@hypha-platform/authentication": "workspace:*", "@hypha-platform/cookie": "workspace:*", "@hypha-platform/core": "workspace:*", @@ -23,6 +25,7 @@ "@hypha-platform/storage-postgres": "workspace:*", "@hypha-platform/ui": "workspace:*", "@hypha-platform/ui-utils": "workspace:*", + "ai": "^6.0.105", "d3": "^7.9.0", "fetch-socks": "^1.3.2", "next": "^15.4.8", diff --git a/apps/web/src/app/[lang]/dho/[id]/@aside/[tab]/chat/[chatId]/page.tsx b/apps/web/src/app/[lang]/dho/[id]/@aside/[tab]/chat/[chatId]/page.tsx new file mode 100644 index 0000000000..171f06d4c3 --- /dev/null +++ b/apps/web/src/app/[lang]/dho/[id]/@aside/[tab]/chat/[chatId]/page.tsx @@ -0,0 +1,47 @@ +'use client'; + +import { + ChatDetail, + ChatPageParams, + SidePanel, + useConversation, +} from '@hypha-platform/epics'; +import { useParams } from 'next/navigation'; +import { getDhoPathCoherence } from '../../../../@tab/coherence/constants'; +import { usePersonById } from '@hypha-platform/core/client'; + +export default function ChatPage() { + const { lang, id: spaceId, chatId } = useParams(); + const { + conversation, + isLoading: isConversationLoading, + error, + } = useConversation({ + chatId, + }); + const { isLoading: isPersonLoading, person: creator } = usePersonById({ + id: conversation?.creatorId, + }); + + const closeUrl = getDhoPathCoherence(lang, spaceId); + + return ( + + {error ? ( +
{error}
+ ) : ( + + )} +
+ ); +} diff --git a/apps/web/src/app/[lang]/dho/[id]/@aside/[tab]/new-signal/page.tsx b/apps/web/src/app/[lang]/dho/[id]/@aside/[tab]/new-signal/page.tsx new file mode 100644 index 0000000000..85435c2f72 --- /dev/null +++ b/apps/web/src/app/[lang]/dho/[id]/@aside/[tab]/new-signal/page.tsx @@ -0,0 +1,29 @@ +import { CreateSignalForm, SidePanel } from '@hypha-platform/epics'; +import { getDhoPathCoherence } from '../../../@tab/coherence/constants'; +import { Locale } from '@hypha-platform/i18n'; +import { findSpaceBySlug } from '@hypha-platform/core/server'; +import { db } from '@hypha-platform/storage-postgres'; +import { notFound } from 'next/navigation'; + +type PageProps = { + params: Promise<{ lang: Locale; id: string; tab: string }>; +}; + +export default async function NewSignalPage({ params }: PageProps) { + const { lang, id } = await params; + + const spaceFromDb = await findSpaceBySlug({ slug: id }, { db }); + + if (!spaceFromDb) notFound(); + + const successfulUrl = getDhoPathCoherence(lang, id); + return ( + + + + ); +} diff --git a/apps/web/src/app/[lang]/dho/[id]/@aside/coherence/default.tsx b/apps/web/src/app/[lang]/dho/[id]/@aside/coherence/default.tsx new file mode 100644 index 0000000000..11a4535598 --- /dev/null +++ b/apps/web/src/app/[lang]/dho/[id]/@aside/coherence/default.tsx @@ -0,0 +1,2 @@ +import Page from './page'; +export default Page; diff --git a/apps/web/src/app/[lang]/dho/[id]/@aside/coherence/page.tsx b/apps/web/src/app/[lang]/dho/[id]/@aside/coherence/page.tsx new file mode 100644 index 0000000000..5e499c5b11 --- /dev/null +++ b/apps/web/src/app/[lang]/dho/[id]/@aside/coherence/page.tsx @@ -0,0 +1,3 @@ +export default async function Index() { + return null; +} diff --git a/apps/web/src/app/[lang]/dho/[id]/@tab/coherence/constants.ts b/apps/web/src/app/[lang]/dho/[id]/@tab/coherence/constants.ts new file mode 100644 index 0000000000..4d7f49c9bb --- /dev/null +++ b/apps/web/src/app/[lang]/dho/[id]/@tab/coherence/constants.ts @@ -0,0 +1,5 @@ +import { Locale } from '@hypha-platform/i18n'; + +export const getDhoPathCoherence = (lang: Locale, id: string) => { + return `/${lang}/dho/${id}/coherence`; +}; diff --git a/apps/web/src/app/[lang]/dho/[id]/@tab/coherence/default.tsx b/apps/web/src/app/[lang]/dho/[id]/@tab/coherence/default.tsx new file mode 100644 index 0000000000..11a4535598 --- /dev/null +++ b/apps/web/src/app/[lang]/dho/[id]/@tab/coherence/default.tsx @@ -0,0 +1,2 @@ +import Page from './page'; +export default Page; diff --git a/apps/web/src/app/[lang]/dho/[id]/@tab/coherence/error.tsx b/apps/web/src/app/[lang]/dho/[id]/@tab/coherence/error.tsx new file mode 100644 index 0000000000..9056aa4ed1 --- /dev/null +++ b/apps/web/src/app/[lang]/dho/[id]/@tab/coherence/error.tsx @@ -0,0 +1,19 @@ +'use client'; // Error boundaries must be Client Components + +import { ErrorComponent } from '@web/components/error'; + +export default function ErrorBoundary({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( + + ); +} diff --git a/apps/web/src/app/[lang]/dho/[id]/@tab/coherence/page.tsx b/apps/web/src/app/[lang]/dho/[id]/@tab/coherence/page.tsx new file mode 100644 index 0000000000..cef7f7c4c1 --- /dev/null +++ b/apps/web/src/app/[lang]/dho/[id]/@tab/coherence/page.tsx @@ -0,0 +1,29 @@ +import { + COHERENCE_ORDERS, + CoherenceBlock, + CoherenceOrder, +} from '@hypha-platform/epics'; +import { Locale } from '@hypha-platform/i18n'; + +type PageProps = { + params: Promise<{ lang: Locale; id: string }>; + searchParams?: Promise<{ + order?: string; + type?: string; + }>; +}; + +export default async function CoherencePage(props: PageProps) { + const params = await props.params; + const searchParams = await props.searchParams; + + const { lang, id } = params; + + const orderRaw = searchParams?.order; + const order: CoherenceOrder = + orderRaw && COHERENCE_ORDERS.includes(orderRaw as CoherenceOrder) + ? (orderRaw as CoherenceOrder) + : 'mostrecent'; + + return ; +} diff --git a/apps/web/src/app/[lang]/dho/[id]/_components/navigation-tabs.tsx b/apps/web/src/app/[lang]/dho/[id]/_components/navigation-tabs.tsx index 409e49b3ca..8a4449f4fa 100644 --- a/apps/web/src/app/[lang]/dho/[id]/_components/navigation-tabs.tsx +++ b/apps/web/src/app/[lang]/dho/[id]/_components/navigation-tabs.tsx @@ -10,6 +10,7 @@ import { getDhoPathTreasury } from '../@tab/treasury/constants'; // import { getDhoPathOverview } from '../@tab/overview/constants'; // Overview tab removed import { ScrollArea, ScrollBar } from '@hypha-platform/ui'; import { getActiveTabFromPath } from '@hypha-platform/epics'; +import { getDhoPathCoherence } from '../@tab/coherence/constants'; export function NavigationTabs({ lang, id }: { lang: Locale; id: string }) { const pathname = usePathname(); @@ -22,6 +23,11 @@ export function NavigationTabs({ lang, id }: { lang: Locale; id: string }) { // name: 'overview', // href: getDhoPathOverview(lang, id), // }, + { + title: 'Coherence', + name: 'coherence', + href: getDhoPathCoherence(lang, id), + }, { title: 'Agreements', name: 'agreements', diff --git a/apps/web/src/app/api/chat/route.ts b/apps/web/src/app/api/chat/route.ts new file mode 100644 index 0000000000..05ad44a7c8 --- /dev/null +++ b/apps/web/src/app/api/chat/route.ts @@ -0,0 +1,42 @@ +import { convertToModelMessages, streamText } from 'ai'; +import { google } from '@ai-sdk/google'; +import type { UIMessage } from 'ai'; +import { headers } from 'next/headers'; +import { NextResponse } from 'next/server'; + +export const maxDuration = 30; + +const SYSTEM_PROMPT = + 'You are Hypha AI, a helpful assistant for the Hypha DAO platform. You help users analyze signals, draft proposals, understand community dynamics, and coordinate across spaces. Be concise and helpful.'; + +function getModel(modelId: string) { + switch (modelId) { + case 'gemini-2.5-flash': + return google('gemini-2.5-flash'); + default: + return google('gemini-2.5-flash'); + } +} + +export async function POST(req: Request) { + const headersList = await headers(); + const authToken = headersList.get('Authorization')?.split(' ')[1] || ''; + if (!authToken) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { + messages, + modelId = 'gemini-2.5-flash', + }: { messages: UIMessage[]; modelId?: string } = await req.json(); + + const model = getModel(modelId); + + const result = streamText({ + model, + system: SYSTEM_PROMPT, + messages: await convertToModelMessages(messages), + }); + + return result.toUIMessageStreamResponse(); +} diff --git a/apps/web/src/app/api/matrix/token/route.ts b/apps/web/src/app/api/matrix/token/route.ts new file mode 100644 index 0000000000..9196dd985f --- /dev/null +++ b/apps/web/src/app/api/matrix/token/route.ts @@ -0,0 +1,259 @@ +import { + createMatrixUserLinkAction, + decryptMatrixToken, + determineEnvironment, + Environment, + getDecoratedPrivyId, + getLinkByPrivyUserId, + getAdminUserNameAction, + MatrixSharedSecret, + MatrixUserLink, + updateEncryptedAccessTokenAction, +} from '@hypha-platform/core/server'; +import { PrivyClient } from '@privy-io/server-auth'; +import { NextRequest, NextResponse } from 'next/server'; +import { randomUUID } from 'node:crypto'; + +const PRIVY_APP_ID = process.env.NEXT_PUBLIC_PRIVY_APP_ID ?? ''; +const PRIVY_APP_SECRET = process.env.PRIVY_APP_SECRET ?? ''; +const MATRIX_HOMESERVER_URL = + process.env.NEXT_PUBLIC_MATRIX_HOMESERVER_URL ?? ''; +const DEFAULT_ROOM_ID = process.env.DEFAULT_ROOM_ID ?? ''; +const ADMIN_BASE_NAME = 'hypha_admin'; + +export async function GET(request: NextRequest) { + const authHeader = request.headers.get('Authorization'); + if (!authHeader?.startsWith('Bearer ')) { + return NextResponse.json( + { + error: 'Unauthorized', + }, + { + status: 401, + }, + ); + } + + const authToken = authHeader.replace('Bearer ', ''); + const privyUserId = await (async (token: string) => { + try { + const privy = new PrivyClient(PRIVY_APP_ID, PRIVY_APP_SECRET); + const { userId } = await privy.verifyAuthToken(token); + return userId; + } catch (error) { + console.warn('Auth error:', error); + return null; + } + })(authToken); + + if (!privyUserId) { + return NextResponse.json( + { + error: 'Unauthorized', + }, + { + status: 401, + }, + ); + } + + const matrixAuthClient = new MatrixSharedSecret(); + + const getAdminMatrixUserName = async ( + environment: Environment, + ): Promise => { + const adminUsername = + (await getAdminUserNameAction( + { baseName: ADMIN_BASE_NAME, environment }, + { authToken }, + )) ?? `${ADMIN_BASE_NAME}_${randomUUID()}`; + return adminUsername; + }; + + const getAdminRecord = async ( + adminUsername: string, + environment: Environment, + authToken: string, + ) => { + const record = await getLinkByPrivyUserId({ + privyUserId: adminUsername, + environment, + }); + if (record) { + return record; + } + const { + accessToken: encryptedAccessToken, + deviceId, + userId: matrixUserId, + } = await matrixAuthClient.registerUser(adminUsername, true); + return (await createMatrixUserLinkAction( + { + environment, + encryptedAccessToken, + deviceId, + matrixUserId, + privyUserId: adminUsername, + }, + { authToken }, + )) as MatrixUserLink; + }; + + try { + const environment = determineEnvironment(request.url); + + const existing = await getLinkByPrivyUserId({ privyUserId, environment }); + if (existing) { + const accessToken = decryptMatrixToken(existing.encryptedAccessToken); + if (await matrixAuthClient.validateToken(accessToken)) { + return NextResponse.json({ + accessToken, + userId: existing.matrixUserId, + homeserverUrl: MATRIX_HOMESERVER_URL, + deviceId: existing.deviceId, + elementConfig: { + // defaultRoomId: DEFAULT_ROOM_ID, + theme: 'dark', + }, + }); + } else { + const adminMatrixUsername = await getAdminMatrixUserName(environment); + const admin = await getAdminRecord( + adminMatrixUsername, + environment, + authToken, + ); + if (admin) { + const adminAccessToken = decryptMatrixToken( + admin.encryptedAccessToken, + ); + const { ok, password } = await matrixAuthClient.resetPassword( + existing.matrixUserId, + adminAccessToken, + ); + if (ok) { + const { + accessToken: encryptedAccessToken, + deviceId, + userId: matrixUserId, + } = await matrixAuthClient.loginUser( + existing.matrixUserId, + password, + ); + + await updateEncryptedAccessTokenAction( + { + privyUserId, + environment, + encryptedAccessToken, + }, + { authToken }, + ); + + return NextResponse.json({ + accessToken: decryptMatrixToken(encryptedAccessToken), + userId: matrixUserId, + homeserverUrl: MATRIX_HOMESERVER_URL, + deviceId, + elementConfig: { + // defaultRoomId: DEFAULT_ROOM_ID, + theme: 'dark', + }, + }); + } + + throw new Error('Matrix user link exists but cannot be updated'); + } + } + } + + const matrixUsername = getDecoratedPrivyId(privyUserId, environment); + const { + accessToken: encryptedAccessToken, + deviceId, + userId: matrixUserId, + } = await matrixAuthClient.registerUser(matrixUsername); + + if (!encryptedAccessToken) { + const adminMatrixUsername = await getAdminMatrixUserName(environment); + const admin = await getAdminRecord( + adminMatrixUsername, + environment, + authToken, + ); + const adminAccessToken = decryptMatrixToken(admin.encryptedAccessToken); + const userInfo = await matrixAuthClient.getUser( + matrixUsername, + adminAccessToken, + ); + + const { ok, password } = await matrixAuthClient.resetPassword( + userInfo.userId, + adminAccessToken, + ); + if (ok) { + const { + accessToken: encryptedAccessToken, + deviceId, + userId, + } = await matrixAuthClient.loginUser(userInfo.userId, password); + + await createMatrixUserLinkAction( + { + environment, + encryptedAccessToken, + deviceId, + matrixUserId: userId, + privyUserId, + }, + { authToken }, + ); + + return NextResponse.json({ + accessToken: decryptMatrixToken(encryptedAccessToken), + userId, + homeserverUrl: MATRIX_HOMESERVER_URL, + deviceId, + elementConfig: { + // defaultRoomId: DEFAULT_ROOM_ID, + theme: 'dark', + }, + }); + } + + throw new Error('Matrix user link exists but cannot be updated'); + } + + await createMatrixUserLinkAction( + { + environment, + encryptedAccessToken, + deviceId, + matrixUserId, + privyUserId, + }, + { authToken }, + ); + + return NextResponse.json({ + accessToken: decryptMatrixToken(encryptedAccessToken), + userId: matrixUserId, + homeserverUrl: MATRIX_HOMESERVER_URL, + deviceId, + elementConfig: { + // defaultRoomId: DEFAULT_ROOM_ID, + theme: 'dark', + }, + }); + } catch (error) { + console.warn('Token generation failed:', error); + return NextResponse.json( + { + error: 'Token generation failed', + }, + { + status: 500, + }, + ); + } +} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 3a09aa1185..8553da5e06 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -9,9 +9,12 @@ import type { Metadata } from 'next'; import { Footer, Html, ThemeProvider } from '@hypha-platform/ui/server'; import { AuthProvider } from '@hypha-platform/authentication'; import { useAuthentication } from '@hypha-platform/authentication'; -import { ConnectedButtonProfile } from '@hypha-platform/epics'; +import { + AiLeftPanelLayout, + ConnectedButtonProfile, +} from '@hypha-platform/epics'; import { EvmProvider } from '@hypha-platform/evm'; -import { useMe } from '@hypha-platform/core/client'; +import { MatrixProvider, useMe } from '@hypha-platform/core/client'; import { fileRouter } from '@hypha-platform/core/server'; import { HYPHA_LOCALE } from '@hypha-platform/cookie'; import { i18nConfig } from '@hypha-platform/i18n'; @@ -110,31 +113,39 @@ export default async function RootLayout({ safariWebId={safariWebId} serviceWorkerPath={serviceWorkerPath} > - - - - -
-
-
{children}
+ +
+ + + + +
+ + <> +
{children}
+
+ + +
-
-
+ diff --git a/apps/web/src/lib/middleware/next.ts b/apps/web/src/lib/middleware/next.ts index 8a27f6f14a..ac8f4cde42 100644 --- a/apps/web/src/lib/middleware/next.ts +++ b/apps/web/src/lib/middleware/next.ts @@ -41,6 +41,7 @@ export function cspMiddleware(): NextMiddlewareFunction { const imageHosts = process.env.NEXT_PUBLIC_IMAGE_HOSTS?.split(', ') ?? []; const connectSources = process.env.NEXT_PUBLIC_CONNECT_SOURCES?.split(', ') ?? []; + const elementUrl = process.env.NEXT_PUBLIC_ELEMENT_URL ?? ''; const imageSrc = [ 'data:', @@ -49,6 +50,7 @@ export function cspMiddleware(): NextMiddlewareFunction { const connectSrc = [ ...connectSources, process.env.NEXT_PUBLIC_RPC_URL ?? '', + process.env.NEXT_PUBLIC_MATRIX_HOMESERVER_URL ?? '', ].join(' '); return (request: NextRequest) => { @@ -71,9 +73,9 @@ export function cspMiddleware(): NextMiddlewareFunction { "object-src 'none'", "base-uri 'self'", "form-action 'self'", - "frame-ancestors 'none'", - 'child-src https://auth.privy.io https://verify.walletconnect.com https://verify.walletconnect.org', - 'frame-src https://auth.privy.io https://verify.walletconnect.com https://verify.walletconnect.org https://challenges.cloudflare.com', + "frame-ancestors 'self'", + `child-src 'self' https://auth.privy.io https://verify.walletconnect.com https://verify.walletconnect.org ${elementUrl}`, + `frame-src 'self' https://auth.privy.io https://verify.walletconnect.com https://verify.walletconnect.org https://challenges.cloudflare.com ${elementUrl}`, `connect-src 'self' ${connectSrc}`, "worker-src 'self'", "manifest-src 'self'", diff --git a/package.json b/package.json index e8aab374c5..682c0a67f8 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@next/env": "^15.1.6", "@openzeppelin/contracts": "^5.2.0", "@privy-io/react-auth": "^2.20.0", + "@privy-io/server-auth": "^1.32.5", "@privy-io/wagmi": "^1.0.5", "@radix-ui/react-aspect-ratio": "^1.1.2", "@radix-ui/react-avatar": "^1.1.2", @@ -121,7 +122,7 @@ "wagmi": "^2.14.12", "websocket": "^1.0.35", "ws": "^8.18.1", - "zod": "^3.24.2" + "zod": "^3.25.76" }, "devDependencies": { "@babel/core": "^7.26.9", @@ -252,4 +253,4 @@ "vitest": "^1.3.1", "zx": "^8.3.2" } -} +} \ No newline at end of file diff --git a/packages/core/package.json b/packages/core/package.json index 9e58d98382..0de648524f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -16,7 +16,8 @@ "@hypha-platform/storage-postgres": "workspace:*", "@hypha-platform/ui": "workspace:*", "@hypha-platform/ui-utils": "workspace:*", - "@wagmi/cli": "^2.2.0" + "@wagmi/cli": "^2.2.0", + "matrix-js-sdk": "^40.0.0" }, "exports": { "./client": "./src/client.ts", @@ -24,6 +25,8 @@ "./space/server/actions": "./src/space/server/actions.ts", "./people/server/actions": "./src/people/server/actions.ts", "./governance/server/actions": "./src/governance/server/actions.ts", + "./coherence/server/actions": "./src/coherence/server/actions.ts", + "./matrix/server/actions": "./src/matrix/server/actions.ts", "./generated": "./src/generated.ts" } -} +} \ No newline at end of file diff --git a/packages/core/src/client.ts b/packages/core/src/client.ts index 75f8af4828..cd4adcbc2b 100644 --- a/packages/core/src/client.ts +++ b/packages/core/src/client.ts @@ -7,3 +7,5 @@ export * from './space'; export * from './transaction'; export * from './events'; export * from './notifications'; +export * from './coherence'; +export * from './matrix'; diff --git a/packages/core/src/coherence/client/hooks/index.ts b/packages/core/src/coherence/client/hooks/index.ts new file mode 100644 index 0000000000..15f326a23a --- /dev/null +++ b/packages/core/src/coherence/client/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './useCoherenceMutations.web2.rsc'; +export * from './useFindCoherences'; diff --git a/packages/core/src/coherence/client/hooks/useCoherenceMutations.web2.rsc.ts b/packages/core/src/coherence/client/hooks/useCoherenceMutations.web2.rsc.ts new file mode 100644 index 0000000000..58a24a8da4 --- /dev/null +++ b/packages/core/src/coherence/client/hooks/useCoherenceMutations.web2.rsc.ts @@ -0,0 +1,67 @@ +'use client'; + +import useSWRMutation from 'swr/mutation'; +import { + createCoherenceAction, + deleteCoherenceBySlugAction, + updateCoherenceBySlugAction, +} from '../../server/actions'; +import { CreateCoherenceInput, UpdateCoherenceBySlugInput } from '../../types'; + +export const useCoherenceMutationsWeb2Rsc = (authToken?: string | null) => { + const { + trigger: createCoherenceMutation, + reset: resetCreateCoherenceMutation, + isMutating: isCreatingCoherence, + error: errorCreateCoherenceMutation, + data: createdCoherence, + } = useSWRMutation( + authToken ? [authToken, 'createCoherence'] : null, + async ([authToken], { arg }: { arg: CreateCoherenceInput }) => + createCoherenceAction(arg, { authToken }), + ); + + const { + trigger: updateCoherenceBySlugMutation, + reset: resetUpdateCoherenceBySlugMutation, + isMutating: isUpdatingCoherence, + error: errorUpdateCoherenceBySlugMutation, + data: updatedCoherence, + } = useSWRMutation( + authToken ? [authToken, 'updateCoherence'] : null, + async ([authToken], { arg }: { arg: UpdateCoherenceBySlugInput }) => + updateCoherenceBySlugAction(arg, { authToken }), + ); + + const { + trigger: deleteCoherenceBySlugMutation, + reset: resetDeleteCoherenceBySlugMutation, + isMutating: isDeletingCoherence, + error: errorDeleteCoherenceBySlugMutation, + data: deletedCoherence, + } = useSWRMutation( + authToken ? [authToken, 'deleteCoherence'] : null, + async ([authToken], { arg }: { arg: { slug: string } }) => + deleteCoherenceBySlugAction(arg, { authToken }), + ); + + return { + createCoherence: createCoherenceMutation, + resetCreateCoherenceMutation, + isCreatingCoherence, + errorCreateCoherenceMutation, + createdCoherence, + + updateCoherenceBySlug: updateCoherenceBySlugMutation, + resetUpdateCoherenceBySlugMutation, + isUpdatingCoherence, + errorUpdateCoherenceBySlugMutation, + updatedCoherence, + + deleteCoherenceBySlug: deleteCoherenceBySlugMutation, + resetDeleteCoherenceBySlugMutation, + isDeletingCoherence, + errorDeleteCoherenceBySlugMutation, + deletedCoherence, + }; +}; diff --git a/packages/core/src/coherence/client/hooks/useFindCoherences.ts b/packages/core/src/coherence/client/hooks/useFindCoherences.ts new file mode 100644 index 0000000000..10c6e70d02 --- /dev/null +++ b/packages/core/src/coherence/client/hooks/useFindCoherences.ts @@ -0,0 +1,68 @@ +'use client'; + +import useSWR from 'swr'; +import { getAllCoherences } from '../../server/web3'; +import { CoherenceType } from '../../coherence-types'; +import { CoherenceTag } from '../../coherence-tags'; +import { CoherencePriority } from '../../coherence-priorities'; + +export interface CoherenceQuery { + spaceId?: number; + search?: string; + type?: CoherenceType; + tags?: CoherenceTag[]; + priority?: CoherencePriority; + includeArchived?: boolean; +} + +export const useFindCoherences = ({ + spaceId, + search, + type, + tags, + priority, + includeArchived, + orderBy, +}: { + spaceId?: number; + search?: string; + type?: CoherenceType; + tags?: CoherenceTag[]; + priority?: CoherencePriority; + includeArchived?: boolean; + orderBy?: string; +}) => { + const { + data: coherences, + isLoading, + error, + mutate: refresh, + } = useSWR( + [ + { spaceId, search, type, tags, priority, includeArchived }, + 'getAllCoherences', + ], + async ([{ search, type, tags, priority, includeArchived }]) => + await getAllCoherences({ + spaceId, + search, + type, + tags, + priority, + includeArchived, + orderBy, + }), + { + refreshInterval: 5000, + keepPreviousData: true, + revalidateOnFocus: true, + }, + ); + + return { + coherences, + isLoading, + error, + refresh, + }; +}; diff --git a/packages/core/src/coherence/client/index.ts b/packages/core/src/coherence/client/index.ts new file mode 100644 index 0000000000..63edeaf577 --- /dev/null +++ b/packages/core/src/coherence/client/index.ts @@ -0,0 +1,6 @@ +export * from './hooks'; +export * from '../coherence-types'; +export * from '../coherence-priorities'; +export * from '../coherence-tags'; +export * from '../lib/get-prefix-by-environment'; +export * from '../lib/determine-environment'; diff --git a/packages/core/src/coherence/coherence-priorities.ts b/packages/core/src/coherence/coherence-priorities.ts new file mode 100644 index 0000000000..61bd983019 --- /dev/null +++ b/packages/core/src/coherence/coherence-priorities.ts @@ -0,0 +1,29 @@ +export const COHERENCE_PRIORITIES = ['high', 'medium', 'low'] as const; + +export type CoherencePriority = (typeof COHERENCE_PRIORITIES)[number]; + +export const COHERENCE_PRIORITY_OPTIONS: { + priority: CoherencePriority; + title: string; + description: string; + colorVariant: string; +}[] = [ + { + priority: 'high', + title: 'High', + colorVariant: 'error', + description: 'Needs immediate attention', + }, + { + priority: 'medium', + title: 'Medium', + colorVariant: 'warn', + description: 'Monitor and act soon', + }, + { + priority: 'low', + title: 'Low', + colorVariant: 'success', + description: 'Informational, no rush', + }, +]; diff --git a/packages/core/src/coherence/coherence-tags.ts b/packages/core/src/coherence/coherence-tags.ts new file mode 100644 index 0000000000..0eec95284b --- /dev/null +++ b/packages/core/src/coherence/coherence-tags.ts @@ -0,0 +1,12 @@ +export const COHERENCE_TAGS = [ + 'Strategy', + 'Culture', + 'Onboarding', + 'Engagement', + 'Learning', + 'Capacity', + 'Network', + 'Reputation', +] as const; + +export type CoherenceTag = (typeof COHERENCE_TAGS)[number]; diff --git a/packages/core/src/coherence/coherence-types.ts b/packages/core/src/coherence/coherence-types.ts new file mode 100644 index 0000000000..30dd4c3bac --- /dev/null +++ b/packages/core/src/coherence/coherence-types.ts @@ -0,0 +1,61 @@ +export const COHERENCE_TYPES = [ + 'Opportunity', + 'Risk', + 'Tension', + 'Insight', + 'Trend', + 'Proposal', +] as const; + +export type CoherenceType = (typeof COHERENCE_TYPES)[number]; + +export const COHERENCE_TYPE_OPTIONS: { + icon: string; + colorVariant: string; + type: CoherenceType; + title: string; + description: string; +}[] = [ + { + icon: 'ArrowUpRight', + colorVariant: 'success', + type: 'Opportunity', + title: 'Opportunity', + description: 'Coordination window or positive opening', + }, + { + icon: 'TriangleAlert', + colorVariant: 'error', + type: 'Risk', + title: 'Risk', + description: 'Threat, concern or danger ahead', + }, + { + icon: 'Flame', + colorVariant: 'tension', + type: 'Tension', + title: 'Tension', + description: 'Conflict or disagreement needing resolution', + }, + { + icon: 'Lightbulb', + colorVariant: 'insight', + type: 'Insight', + title: 'Insight', + description: 'Data-driven observation or discovery', + }, + { + icon: 'TrendingUp', + colorVariant: 'warn', + type: 'Trend', + title: 'Trend', + description: 'Emerging pattern across spaces', + }, + { + icon: 'FileText', + colorVariant: 'accent', + type: 'Proposal', + title: 'Proposal', + description: 'Governance action or vote needed', + }, +] as const; diff --git a/packages/core/src/coherence/index.ts b/packages/core/src/coherence/index.ts new file mode 100644 index 0000000000..e507d42ef0 --- /dev/null +++ b/packages/core/src/coherence/index.ts @@ -0,0 +1,6 @@ +export * from './client'; +export * from './validation'; +export * from './coherence-types'; +export * from './coherence-tags'; +export * from './coherence-priorities'; +export * from './types'; diff --git a/packages/core/src/coherence/lib/determine-environment.ts b/packages/core/src/coherence/lib/determine-environment.ts new file mode 100644 index 0000000000..d04289b40c --- /dev/null +++ b/packages/core/src/coherence/lib/determine-environment.ts @@ -0,0 +1,17 @@ +import { Environment } from '../types'; + +export function determineEnvironment(url: string) { + const hostname = new URL(url).hostname; + + if (hostname.includes('localhost') || hostname.includes('127.0.0.1')) { + return Environment.DEVELOPMENT; + } + if (hostname.includes('.vercel.app')) { + return Environment.PREVIEW; + } + if (hostname.includes('hypha.earth')) { + return Environment.PRODUCTION; + } + + return Environment.PRODUCTION; +} diff --git a/packages/core/src/coherence/lib/get-decorated-privy-id.ts b/packages/core/src/coherence/lib/get-decorated-privy-id.ts new file mode 100644 index 0000000000..4a3203e1e8 --- /dev/null +++ b/packages/core/src/coherence/lib/get-decorated-privy-id.ts @@ -0,0 +1,15 @@ +import { Environment } from '../types'; +import { getPrefixByEnvironment } from './get-prefix-by-environment'; + +function getDecoratedPrivyId(privyUserId: string, environment: Environment) { + if (!privyUserId) { + return ''; + } + const prefix = getPrefixByEnvironment(environment); + const matrixUsername = `${prefix}_privy_${privyUserId + .replace(/[^a-z0-9]/gi, '_') + .toLowerCase()}`; + return matrixUsername; +} + +export { getDecoratedPrivyId }; diff --git a/packages/core/src/coherence/lib/get-prefix-by-environment.ts b/packages/core/src/coherence/lib/get-prefix-by-environment.ts new file mode 100644 index 0000000000..ace2b79749 --- /dev/null +++ b/packages/core/src/coherence/lib/get-prefix-by-environment.ts @@ -0,0 +1,14 @@ +import { Environment } from '../types'; + +export function getPrefixByEnvironment(environment: Environment) { + switch (environment) { + case Environment.DEVELOPMENT: + return 'dev'; + case Environment.PREVIEW: + return 'prev'; + case Environment.PRODUCTION: + return 'prod'; + default: + return 'prod'; + } +} diff --git a/packages/core/src/coherence/lib/index.ts b/packages/core/src/coherence/lib/index.ts new file mode 100644 index 0000000000..6b4b706b03 --- /dev/null +++ b/packages/core/src/coherence/lib/index.ts @@ -0,0 +1,3 @@ +export * from './determine-environment'; +export * from './get-decorated-privy-id'; +export * from './matrix-shared-secret'; diff --git a/packages/core/src/coherence/lib/matrix-shared-secret.ts b/packages/core/src/coherence/lib/matrix-shared-secret.ts new file mode 100644 index 0000000000..0182ced717 --- /dev/null +++ b/packages/core/src/coherence/lib/matrix-shared-secret.ts @@ -0,0 +1,264 @@ +import crypto from 'node:crypto'; +import { hashHmacSha1Hex } from '../../common/server/encrypt-aes'; +import { encryptMatrixToken } from '../../common/server/encrypt-matrix-token'; + +type VersionsResponse = { + versions: Array; + unstable_features: Record; +}; + +type RegisterResponse = { + accessToken: string; + userId: string; + deviceId: string; +}; + +export class MatrixSharedSecret { + private registrationSharedSecret = + process.env.MATRIX_REGISTRATION_SHARED_SECRET!; + private matrixHomeserverUrl = process.env.NEXT_PUBLIC_MATRIX_HOMESERVER_URL!; + private matrixDomain = process.env.MATRIX_DOMAIN!; + private versions: VersionsResponse = { versions: [], unstable_features: {} }; + + private async getVersions(): Promise { + if (this.versions.versions.length === 0) { + const response = await fetch( + `${this.matrixHomeserverUrl}/_matrix/client/versions`, + ); + if (response.ok) { + this.versions = await response.json(); + } + } + return this.versions; + } + + private async getEffectiveVersion() { + const versions = await this.getVersions(); + const useV3 = versions.versions?.includes('v1.3') || false; + const result = useV3 ? 'v3' : 'r0'; + return result; + } + + private async getNonce(): Promise { + const response = await fetch( + `${this.matrixHomeserverUrl}/_synapse/admin/v1/register`, + { + headers: { + Authorization: `Bearer ${this.registrationSharedSecret}`, + }, + }, + ); + if (!response.ok) { + return ''; + } + const data = await response.json(); + return data.nonce; + } + + async isUsernameAvailable(username: string) { + const version = await this.getEffectiveVersion(); + const endpoint = `/_matrix/client/${version}/register/available`; + + const response = await fetch( + `${this.matrixHomeserverUrl}${endpoint}?username=${username}`, + ); + if (!response.ok) { + return false; + } + const available = await response.json(); + return available.available; + } + + async getUser(userName: string, adminAccessToken: string) { + const version = await this.getEffectiveVersion(); + const matrixUserName = `@${userName}:${this.matrixDomain}`; + const endpoint = `/_matrix/client/${version}/admin/whois/${matrixUserName}`; + const response = await fetch(`${this.matrixHomeserverUrl}${endpoint}`, { + headers: { + Authorization: `Bearer ${adminAccessToken}`, + }, + }); + const result = await response.json(); + return { + userId: result.user_id, + devices: result.devices, + }; + } + + async registerUserNonce(username: string, isAdmin: boolean = false) { + const nonce = await this.getNonce(); + const endpoint = '/_synapse/admin/v1/register'; + const password = crypto.randomBytes(32).toString('hex'); + const admin = isAdmin ? 'admin' : 'notadmin'; + const text = `${nonce}\0${username}\0${password}\0${admin}`; + const mac = hashHmacSha1Hex(text, this.registrationSharedSecret); + const registerBody = { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.registrationSharedSecret}`, + }, + body: JSON.stringify({ + nonce, + username, + password, + admin: isAdmin, + mac, + }), + } as const; + const response = await fetch( + `${this.matrixHomeserverUrl}${endpoint}`, + registerBody, + ); + return response; + } + + async registerUser( + username: string, + isAdmin: boolean = false, + ): Promise { + const response = await this.registerUserNonce(username, isAdmin); + const data = await response.json(); + + if (!response.ok) { + console.warn('Cannot register user:', data); + + if (data.errcode === 'M_USER_IN_USE') { + return { + accessToken: '', + userId: '', + deviceId: '', + }; + } + } + + await this.changePassword( + data.access_token, + crypto.randomBytes(32).toString('hex'), + ); + + return { + accessToken: encryptMatrixToken(data.access_token), + userId: data.user_id, + deviceId: data.device_id, + }; + } + + async resetPassword(username: string, adminAccessToken: string) { + const password = crypto.randomBytes(32).toString('hex'); + const response = await fetch( + `${this.matrixHomeserverUrl}/_dendrite/admin/resetPassword/${username}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${adminAccessToken}`, + }, + body: JSON.stringify({ + password, + logout_devices: true, + }), + }, + ); + const data = await response.json(); + if (!response.ok) { + console.warn('Cannot reset password', data); + } + return { ok: data.password_updated, password }; + } + + async removeUser(username: string) { + const response = await fetch( + `${this.matrixHomeserverUrl}/_dendrite/admin/deactivate/${username}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + erase: true, + }), + }, + ); + const data = await response.json(); + if (!response.ok) { + console.warn('Cannot remove user', data); + } + return response; + } + + async validateToken(accessToken: string) { + try { + const version = await this.getEffectiveVersion(); + const response = await fetch( + `${this.matrixHomeserverUrl}/_matrix/client/${version}/account/whoami`, + { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }, + ); + + if (!response.ok) { + console.log('Token validation failed:', response.status); + return false; + } + + const data = await response.json(); + return data.user_id && data.user_id.includes('@'); + } catch (error) { + console.error('Token validation error:', error); + return false; + } + } + + async loginUser(username: string, password: string) { + const version = await this.getEffectiveVersion(); + const endpoint = `/_matrix/client/${version}/login`; + + const response = await fetch(`${this.matrixHomeserverUrl}${endpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + identifier: { + type: 'm.id.user', + user: username, + }, + initial_device_display_name: `device_${Date.now()}`, + password, + type: 'm.login.password', + }), + }); + const data = await response.json(); + if (!response.ok) { + console.warn('Cannot login user', data); + } + console.log('Login data response:', data); + return { + accessToken: encryptMatrixToken(data.access_token), + userId: data.user_id, + deviceId: data.device_id, + }; + } + + private async changePassword(accessToken: string, newPassword: string) { + const version = await this.getEffectiveVersion(); + const endpoint = `/_matrix/client/${version}/account/password`; + + const response = await fetch(`${this.matrixHomeserverUrl}${endpoint}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + new_password: newPassword, + logout_devices: true, + }), + }); + const data = await response.json(); + return data; + } +} diff --git a/packages/core/src/coherence/server/actions.ts b/packages/core/src/coherence/server/actions.ts new file mode 100644 index 0000000000..acea047126 --- /dev/null +++ b/packages/core/src/coherence/server/actions.ts @@ -0,0 +1,35 @@ +'use server'; + +import { CreateCoherenceInput, UpdateCoherenceBySlugInput } from '../types'; +import { db } from '@hypha-platform/storage-postgres'; +import { + createCoherence, + deleteCoherenceBySlug, + updateCoherenceBySlug, +} from './mutations'; + +export async function createCoherenceAction( + data: CreateCoherenceInput, + { authToken }: { authToken?: string }, +) { + if (!authToken) throw new Error('authToken is required to create coherence'); + return createCoherence({ ...data }, { db }); +} + +export async function updateCoherenceBySlugAction( + data: UpdateCoherenceBySlugInput, + { authToken }: { authToken?: string }, +) { + // TODO: #602 Define RLS Policies for Spaces Table + // const db = getDb({ authToken }); + return updateCoherenceBySlug(data, { db }); +} + +export async function deleteCoherenceBySlugAction( + data: { slug: string }, + { authToken }: { authToken?: string }, +) { + // TODO: #602 Define RLS Policies for Spaces Table + // const db = getDb({ authToken }); + return deleteCoherenceBySlug(data, { db }); +} diff --git a/packages/core/src/coherence/server/index.ts b/packages/core/src/coherence/server/index.ts new file mode 100644 index 0000000000..41a22fd332 --- /dev/null +++ b/packages/core/src/coherence/server/index.ts @@ -0,0 +1,4 @@ +export * from '../lib'; +export * from './actions'; +export * from './mutations'; +export * from './queries'; diff --git a/packages/core/src/coherence/server/mutations.ts b/packages/core/src/coherence/server/mutations.ts new file mode 100644 index 0000000000..a94a7dc8d9 --- /dev/null +++ b/packages/core/src/coherence/server/mutations.ts @@ -0,0 +1,76 @@ +import { v4 as uuidv4 } from 'uuid'; + +import { DatabaseInstance } from '../../server'; +import { CreateCoherenceInput, UpdateCoherenceInput } from '../types'; +import { coherences } from '@hypha-platform/storage-postgres'; +import { eq } from 'drizzle-orm'; + +export const createCoherence = async ( + { + creatorId, + spaceId, + slug: maybeSlug, + priority: maybePriority, + ...rest + }: CreateCoherenceInput, + { db }: { db: DatabaseInstance }, +) => { + if (creatorId === undefined) { + throw new Error('creatorId is required to create coherence'); + } + if (spaceId === undefined) { + throw new Error('spaceId is required to create coherence'); + } + const slug = maybeSlug || `coh-${uuidv4().slice(0, 8)}`; + const priority = maybePriority || 'low'; + + const [newSignal] = await db + .insert(coherences) + .values({ + creatorId, + spaceId, + slug, + priority, + ...rest, + }) + .returning(); + + if (!newSignal) { + throw new Error('Failed to create coherence'); + } + + return newSignal; +}; + +export const updateCoherenceBySlug = async ( + { slug, ...rest }: { slug: string } & UpdateCoherenceInput, + { db }: { db: DatabaseInstance }, +) => { + const [updatedCoherence] = await db + .update(coherences) + .set({ ...rest }) + .where(eq(coherences.slug, slug)) + .returning(); + + if (!updatedCoherence) { + throw new Error('Failed to update coherence'); + } + + return updatedCoherence; +}; + +export const deleteCoherenceBySlug = async ( + { slug }: { slug: string }, + { db }: { db: DatabaseInstance }, +) => { + const deleted = await db + .delete(coherences) + .where(eq(coherences.slug, slug)) + .returning(); + + if (!deleted || deleted.length === 0) { + throw new Error('Failed to delete coherence'); + } + + return deleted[0]; +}; diff --git a/packages/core/src/coherence/server/queries.ts b/packages/core/src/coherence/server/queries.ts new file mode 100644 index 0000000000..ad12f2eb4e --- /dev/null +++ b/packages/core/src/coherence/server/queries.ts @@ -0,0 +1,122 @@ +import { Coherence, coherences } from '@hypha-platform/storage-postgres'; +import { DbConfig } from '../../server'; +import { and, arrayOverlaps, desc, eq, SQL, sql } from 'drizzle-orm'; +import { CoherenceType } from '../coherence-types'; +import { CoherenceTag } from '../coherence-tags'; +import { CoherencePriority } from '../coherence-priorities'; + +type FindAllCoherencesInput = { + spaceId?: number; + search?: string; + type?: CoherenceType; + tags?: CoherenceTag[]; + priority?: CoherencePriority; + includeArchived?: boolean; + orderBy?: string; +}; + +export const findAllCoherences = async ( + { db }: DbConfig, + { + spaceId, + search, + type, + tags, + priority, + includeArchived = false, + orderBy, + }: FindAllCoherencesInput, +) => { + if (spaceId === undefined) { + return [] as Coherence[]; + } + const order = ((orderRaw): SQL => { + switch (orderRaw) { + case 'mostrecent': + return desc(coherences.createdAt); + case 'mostmessages': + return desc(coherences.messages); + case 'mostviews': + return desc(coherences.views); + default: + return desc(coherences.createdAt); + } + })(orderBy); + const results = await db + .select() + .from(coherences) + .where( + and( + eq(coherences.spaceId, spaceId), + includeArchived ? undefined : eq(coherences.archived, false), + search + ? sql`( + -- Full-text search for exact word matches (highest priority) + (setweight(to_tsvector('english', ${coherences.title}), 'A') || + setweight(to_tsvector('english', ${coherences.description}), 'B') + ) @@ plainto_tsquery('english', ${search}) + OR + -- Partial word matching with ILIKE (case-insensitive) + ${coherences.title} ILIKE ${'%' + search + '%'} + OR + ${coherences.description} ILIKE ${'%' + search + '%'} + )` + : undefined, + type ? eq(coherences.type, type) : undefined, + tags ? arrayOverlaps(coherences.tags, tags) : undefined, + priority ? eq(coherences.priority, priority) : undefined, + ), + ) + .orderBy(order); + + return results; +}; + +export const findCoherencesById = async ( + { id }: { id: number }, + { db }: DbConfig, +): Promise => { + const [coherence] = await db + .select() + .from(coherences) + .where(eq(coherences.id, id)); + return coherence ? coherence : null; +}; + +type FindCoherenceBySlugInput = { + slug: string; +}; + +export const findCoherenceBySlug = async ( + { slug }: FindCoherenceBySlugInput, + { db }: DbConfig, +): Promise => { + const response = await db.query.coherences.findFirst({ + where: (coherences, { eq }) => eq(coherences.slug, slug), + }); + + if (!response) { + return null; + } + + return { + ...response, + }; +}; + +type CheckCoherenceSlugExistsInput = { + slug: string; +}; + +export const checkCoherenceSlugExists = async ( + { slug }: CheckCoherenceSlugExistsInput, + { db }: DbConfig, +): Promise<{ exists: boolean; coherenceId?: number }> => { + const response = await db.query.coherences.findFirst({ + where: (coherences, { eq }) => eq(coherences.slug, slug), + }); + + const exists = !!response; + const coherenceId = response?.id; + return { exists, coherenceId }; +}; diff --git a/packages/core/src/coherence/server/web3/get-all-coherences.ts b/packages/core/src/coherence/server/web3/get-all-coherences.ts new file mode 100644 index 0000000000..17f39c0d06 --- /dev/null +++ b/packages/core/src/coherence/server/web3/get-all-coherences.ts @@ -0,0 +1,54 @@ +'use server'; + +import { db } from '@hypha-platform/storage-postgres'; +import { CoherenceType } from '../../coherence-types'; +import { CoherenceTag } from '../../coherence-tags'; +import { CoherencePriority } from '../../coherence-priorities'; +import { findAllCoherences } from '../queries'; +import { Coherence } from '../../types'; + +type GetAllCoherencesInput = { + spaceId?: number; + search?: string; + type?: CoherenceType; + tags?: CoherenceTag[]; + priority?: CoherencePriority; + includeArchived?: boolean; + orderBy?: string; +}; + +export async function getAllCoherences( + props: GetAllCoherencesInput = {}, +): Promise { + try { + const coherences = await findAllCoherences({ db }, props); + + return coherences.map( + ({ + priority, + type, + roomId, + archived, + slug, + tags, + messages, + views, + ...rest + }): Coherence => ({ + type: type as CoherenceType, + priority: (priority as CoherencePriority) ?? 'low', + tags: tags as CoherenceTag[], + roomId: roomId ?? undefined, + archived: archived ?? false, + slug: slug!, + messages: messages!, + views: views!, + ...rest, + }), + ); + } catch (error) { + throw new Error('Failed to get coherences', { + cause: error instanceof Error ? error : new Error(String(error)), + }); + } +} diff --git a/packages/core/src/coherence/server/web3/get-coherence-by-slug.ts b/packages/core/src/coherence/server/web3/get-coherence-by-slug.ts new file mode 100644 index 0000000000..40cb311951 --- /dev/null +++ b/packages/core/src/coherence/server/web3/get-coherence-by-slug.ts @@ -0,0 +1,25 @@ +'use server'; + +import { db } from '@hypha-platform/storage-postgres'; +import { Coherence } from '../../types'; +import { findCoherenceBySlug } from '../queries'; + +type GetCoherenceBySlugInput = { + slug: string; +}; + +export async function getCoherenceBySlug( + props: GetCoherenceBySlugInput, +): Promise { + try { + const coherence = await findCoherenceBySlug(props, { db }); + + return { + ...coherence, + } as Coherence; + } catch (error) { + throw new Error('Failed to get coherence', { + cause: error instanceof Error ? error : new Error(String(error)), + }); + } +} diff --git a/packages/core/src/coherence/server/web3/index.ts b/packages/core/src/coherence/server/web3/index.ts new file mode 100644 index 0000000000..45bee2fc2f --- /dev/null +++ b/packages/core/src/coherence/server/web3/index.ts @@ -0,0 +1,2 @@ +export * from './get-all-coherences'; +export * from './get-coherence-by-slug'; diff --git a/packages/core/src/coherence/types.ts b/packages/core/src/coherence/types.ts new file mode 100644 index 0000000000..be9067df5d --- /dev/null +++ b/packages/core/src/coherence/types.ts @@ -0,0 +1,53 @@ +import { CoherencePriority } from './coherence-priorities'; +import { CoherenceTag } from './coherence-tags'; +import { CoherenceType } from './coherence-types'; + +export interface CreateCoherenceInput { + creatorId: number; + spaceId: number; + type: CoherenceType; + priority: CoherencePriority; + title: string; + description: string; + slug?: string; + roomId?: string; + archived: boolean; + tags: CoherenceTag[]; + messages?: number; + views?: number; +} + +export interface UpdateCoherenceInput { + slug?: string; + archived?: boolean; + roomId?: string; + messages?: number; + views?: number; +} + +export type UpdateCoherenceBySlugInput = { + slug: string; +} & UpdateCoherenceInput; + +export type Coherence = { + id: number; + creatorId?: number; + createdAt: Date; + updatedAt: Date; + type: CoherenceType; + priority: CoherencePriority; + title: string; + description: string; + slug: string; + roomId?: string; + archived: boolean; + tags: CoherenceTag[]; + messages?: number; + views?: number; +}; + +export enum Environment { + DEVELOPMENT = 'development', + PREVIEW = 'preview', + PRODUCTION = 'production', +} diff --git a/packages/core/src/coherence/validation.ts b/packages/core/src/coherence/validation.ts new file mode 100644 index 0000000000..76693d5a07 --- /dev/null +++ b/packages/core/src/coherence/validation.ts @@ -0,0 +1,50 @@ +import { z } from 'zod'; +import { COHERENCE_TYPES } from './coherence-types'; +import { COHERENCE_TAGS } from './coherence-tags'; +import { COHERENCE_PRIORITIES } from './coherence-priorities'; + +export const createCoherenceWeb2Props = { + type: z.enum(COHERENCE_TYPES), + priority: z.enum(COHERENCE_PRIORITIES), + title: z + .string() + .trim() + .min(1, { message: 'Please add a title for your coherence' }) + .max(50), + description: z + .string() + .trim() + .min(1, { message: 'Please add content to your coherence' }) + .max(4000), + slug: z + .string() + .min(1) + .max(50) + .regex( + /^[a-z0-9-]+$/, + 'Slug must contain only lowercase letters, numbers, and hyphens', + ) + .optional(), + roomId: z + .string() + .min(1) + .max(50) + .regex( + /^[a-z0-9-]+$/, + 'Room ID must contain only lowercase letters, numbers, and hyphens', + ) + .optional(), + creatorId: z.number().min(1), + spaceId: z.number().min(1), + archived: z.boolean(), + tags: z.array(z.enum(COHERENCE_TAGS)).default([]), +}; +export const schemaCreateCoherenceWeb2 = z.object(createCoherenceWeb2Props); + +export const schemaCreateCoherence = z.object({ + ...createCoherenceWeb2Props, +}); + +export const schemaCreateCoherenceForm = z.object({ + ...createCoherenceWeb2Props, +}); diff --git a/packages/core/src/common/server/decrypt-matrix-token.ts b/packages/core/src/common/server/decrypt-matrix-token.ts new file mode 100644 index 0000000000..6d1de60dcc --- /dev/null +++ b/packages/core/src/common/server/decrypt-matrix-token.ts @@ -0,0 +1,7 @@ +import { decryptAes256 } from './encrypt-aes'; + +const MATRIX_PASSWORD_SECRET = process.env.MATRIX_PASSWORD_SECRET ?? ''; + +export function decryptMatrixToken(encryptedToken: string) { + return decryptAes256(encryptedToken, MATRIX_PASSWORD_SECRET); +} diff --git a/packages/core/src/common/server/encrypt-aes.ts b/packages/core/src/common/server/encrypt-aes.ts new file mode 100644 index 0000000000..77891bcebe --- /dev/null +++ b/packages/core/src/common/server/encrypt-aes.ts @@ -0,0 +1,49 @@ +import crypto from 'node:crypto'; + +const algorithm = 'aes-256-cbc'; +const ivLength = 16; + +function encryptAes256(text: string, secretKey: string): string { + if (!secretKey) { + throw new Error('Secret key is incorrect'); + } + // Generate a random IV for each encryption + const iv = crypto.randomBytes(ivLength); + const cipher = crypto.createCipheriv( + algorithm, + Buffer.from(secretKey, 'hex'), + iv, + ); + let encrypted = cipher.update(text, 'utf8', 'hex'); + encrypted += cipher.final('hex'); + // Combine IV and encrypted data for storage/transmission + return iv.toString('hex') + ':' + encrypted; +} + +function decryptAes256(encryptedText: string, secretKey: string): string { + if (!secretKey) { + throw new Error('Secret key is incorrect'); + } + // Split the combined string to retrieve the IV and actual data + const parts = encryptedText.split(':'); + const ivRaw = parts.shift(); + if (ivRaw === undefined) { + return ''; + } + const iv = Buffer.from(ivRaw, 'hex'); + const encrypted = parts.join(':'); + const decipher = crypto.createDecipheriv( + algorithm, + Buffer.from(secretKey, 'hex'), + iv, + ); + let decrypted = decipher.update(encrypted, 'hex', 'utf8'); + decrypted += decipher.final('utf8'); + return decrypted; +} + +function hashHmacSha1Hex(text: string, key: string): string { + return crypto.createHmac('sha1', key).update(text).digest('hex'); +} + +export { encryptAes256, decryptAes256, hashHmacSha1Hex }; diff --git a/packages/core/src/common/server/encrypt-matrix-token.ts b/packages/core/src/common/server/encrypt-matrix-token.ts new file mode 100644 index 0000000000..d455a65b92 --- /dev/null +++ b/packages/core/src/common/server/encrypt-matrix-token.ts @@ -0,0 +1,7 @@ +import { encryptAes256 } from './encrypt-aes'; + +const MATRIX_PASSWORD_SECRET = process.env.MATRIX_PASSWORD_SECRET ?? ''; + +export function encryptMatrixToken(token: string) { + return encryptAes256(token, MATRIX_PASSWORD_SECRET); +} diff --git a/packages/core/src/common/server/index.ts b/packages/core/src/common/server/index.ts index 5958f072a9..e3e132aefa 100644 --- a/packages/core/src/common/server/index.ts +++ b/packages/core/src/common/server/index.ts @@ -15,3 +15,7 @@ export * from './webhooks'; export * from './route-handlers'; export * from './extract-revert-reason'; + +export * from './encrypt-aes'; +export * from './encrypt-matrix-token'; +export * from './decrypt-matrix-token'; diff --git a/packages/core/src/common/server/web3-rpc/get-token-meta.ts b/packages/core/src/common/server/web3-rpc/get-token-meta.ts index 66a87cde4c..13b56452c2 100644 --- a/packages/core/src/common/server/web3-rpc/get-token-meta.ts +++ b/packages/core/src/common/server/web3-rpc/get-token-meta.ts @@ -1,10 +1,10 @@ 'use server'; -import { TOKENS, Token, DbToken, TokenType } from '@hypha-platform/core/client'; -import { findSpaceById } from '../../../server'; import { erc20Abi } from 'viem'; -import { web3Client } from './client'; import { db } from '@hypha-platform/storage-postgres'; +import { web3Client } from './client'; +import { DbToken, findSpaceById } from '../../../server'; +import { Token, TOKENS, TokenType } from '../../web3/tokens'; function getIconForHyphaTokens(symbol: string, fallback: string): string { switch (symbol.toUpperCase()) { diff --git a/packages/core/src/common/server/webhooks/alchemy/middleware.ts b/packages/core/src/common/server/webhooks/alchemy/middleware.ts index f12542f4fd..582b2a970d 100644 --- a/packages/core/src/common/server/webhooks/alchemy/middleware.ts +++ b/packages/core/src/common/server/webhooks/alchemy/middleware.ts @@ -1,4 +1,4 @@ -import { createHmac, timingSafeEqual } from 'crypto'; +import { createHmac, timingSafeEqual } from 'node:crypto'; import { NextResponse } from 'next/server'; import type { Middleware } from '../../route-handlers'; diff --git a/packages/core/src/matrix/client/hooks/index.ts b/packages/core/src/matrix/client/hooks/index.ts new file mode 100644 index 0000000000..7d339b360e --- /dev/null +++ b/packages/core/src/matrix/client/hooks/index.ts @@ -0,0 +1,2 @@ +export * from './use-matrix-token'; +export * from './use-user-privy-id-by-matrix-id'; diff --git a/packages/core/src/matrix/client/hooks/use-matrix-token.ts b/packages/core/src/matrix/client/hooks/use-matrix-token.ts new file mode 100644 index 0000000000..af57edbeed --- /dev/null +++ b/packages/core/src/matrix/client/hooks/use-matrix-token.ts @@ -0,0 +1,57 @@ +'use client'; + +import { useJwt } from '@hypha-platform/core/client'; +import React from 'react'; +import useSWR from 'swr'; + +export interface MatrixTokenData { + accessToken: string; + userId: string; + homeserverUrl: string; + deviceId?: string; + elementConfig: { + defaultRoomId?: string; + theme: string; + }; +} + +export const useMatrixToken = () => { + const [error, setError] = React.useState(null); + const { jwt, isLoadingJwt } = useJwt(); + + const endpoint = isLoadingJwt ? null : '/api/matrix/token'; + + const { data: matrixToken, isLoading } = useSWR( + [endpoint, jwt, 'getMatrixToken'], + async ([endpoint, authToken]) => { + if (!endpoint || !authToken) { + return undefined; + } + try { + const response = await fetch(endpoint, { + headers: { + Authorization: `Bearer ${authToken}`, + }, + }); + if (!response.ok) { + return undefined; + } + const data = await response.json(); + return data as MatrixTokenData; + } catch (err) { + console.warn('Cannot get Matrix token:', err); + setError(err instanceof Error ? err.message : `${err}`); + return undefined; + } + }, + { + errorRetryInterval: 2000, + }, + ); + + return { + isLoading, + matrixToken, + error, + }; +}; diff --git a/packages/core/src/matrix/client/hooks/use-user-privy-id-by-matrix-id.ts b/packages/core/src/matrix/client/hooks/use-user-privy-id-by-matrix-id.ts new file mode 100644 index 0000000000..6879ca1465 --- /dev/null +++ b/packages/core/src/matrix/client/hooks/use-user-privy-id-by-matrix-id.ts @@ -0,0 +1,54 @@ +'use client'; + +import { determineEnvironment, useJwt } from '@hypha-platform/core/client'; +import React from 'react'; +import useSWR from 'swr'; +import { getLinkByMatrixUserId } from '../../server/web3/get-link-by-matrix-user-id'; + +export interface UseUserPrivyIdByMatrixIdInput { + matrixUserId: string; +} + +export const useUserPrivyIdByMatrixId = ({ + matrixUserId, +}: UseUserPrivyIdByMatrixIdInput) => { + const [error, setError] = React.useState(null); + const { jwt, isLoadingJwt } = useJwt(); + + const environment = determineEnvironment(window.location.href); + const arg = isLoadingJwt ? null : { matrixUserId, environment }; + + const { data: privyUserId, isLoading } = useSWR( + [jwt ? arg : null, 'getLinkByMatrixUserId'], + async ([arg]) => { + if (!arg) { + return undefined; + } + const { matrixUserId, environment } = arg; + if (!matrixUserId || !environment) { + return undefined; + } + try { + const response = await getLinkByMatrixUserId({ + matrixUserId, + environment, + }); + if (!response) { + return undefined; + } + const data = response.privyUserId; + return data; + } catch (err) { + console.warn('Cannot get Privy user ID:', err); + setError(err instanceof Error ? err.message : `${err}`); + return undefined; + } + }, + ); + + return { + isLoading, + privyUserId, + error, + }; +}; diff --git a/packages/core/src/matrix/client/index.ts b/packages/core/src/matrix/client/index.ts new file mode 100644 index 0000000000..1cd748a86e --- /dev/null +++ b/packages/core/src/matrix/client/index.ts @@ -0,0 +1,2 @@ +export * from './hooks'; +export * from './providers'; diff --git a/packages/core/src/matrix/client/providers/index.ts b/packages/core/src/matrix/client/providers/index.ts new file mode 100644 index 0000000000..12b1e1a2a9 --- /dev/null +++ b/packages/core/src/matrix/client/providers/index.ts @@ -0,0 +1 @@ +export * from './matrix-provider'; diff --git a/packages/core/src/matrix/client/providers/matrix-provider.tsx b/packages/core/src/matrix/client/providers/matrix-provider.tsx new file mode 100644 index 0000000000..b839fcf98d --- /dev/null +++ b/packages/core/src/matrix/client/providers/matrix-provider.tsx @@ -0,0 +1,261 @@ +'use client'; + +import React from 'react'; +import * as MatrixSdk from 'matrix-js-sdk'; +import { useAuthentication } from '@hypha-platform/authentication'; +import { MatrixTokenData, useMatrixToken } from '../hooks'; +import { Message } from '../../types'; + +interface SendMessageInput { + roomId: string; + message: string; +} + +export type MatrixEventListener = ( + event: MatrixSdk.MatrixEvent, +) => Promise; +export type RoomMessageListener = (message: Message) => Promise; + +interface RoomMessageListenerRecord { + roomId: string; + listener: MatrixEventListener; +} + +interface MatrixContextType { + client: MatrixSdk.MatrixClient | null; + isMatrixAvailable: boolean; + isAuthenticated: boolean; + createRoom: (title: string) => Promise<{ roomId: string }>; + sendMessage: (params: SendMessageInput) => Promise; + getRoomMessages: (roomId: string) => Message[] | null; + joinRoom: (roomId: string) => Promise; + registerRoomListener: (roomId: string, listener: RoomMessageListener) => void; + unregisterRoomListerner: (roomId: string) => void; + registeredRoomListeners: RoomMessageListenerRecord[]; +} + +const MatrixContext = React.createContext(null); + +interface MatrixProviderProps { + children: React.ReactNode; +} + +export const MatrixProvider: React.FC = ({ children }) => { + const { user } = useAuthentication(); + const [client, setClient] = React.useState( + null, + ); + const [isMatrixAvailable, setIsMatrixAvailable] = React.useState(false); + const [isAuthenticated, setIsAuthenticated] = React.useState(false); + const [registeredRoomListeners, setRegisteredRoomListeners] = React.useState< + RoomMessageListenerRecord[] + >([]); + const { + matrixToken, + isLoading: isMatrixTokenLoading, + error: matrixTokenError, + } = useMatrixToken(); + + const initalizeMatrixClient = React.useCallback( + async (matrixToken: MatrixTokenData) => { + if (!matrixToken) { + return; + } + try { + const { accessToken, userId, homeserverUrl, deviceId } = matrixToken; + const matrixClient = MatrixSdk.createClient({ + baseUrl: homeserverUrl, + accessToken, + userId, + deviceId, + }); + + await matrixClient.startClient(); + + setClient(matrixClient); + setIsMatrixAvailable(matrixClient !== null); + setIsAuthenticated(true); + console.log('Matrix client initialized'); + } catch (error) { + console.error('Failed to initialize Matrix client:', error); + } + }, + [], + ); + + React.useEffect(() => { + if (client) { + //NOTE: already initialized + return; + } + if (isMatrixTokenLoading) { + return; + } + if (matrixTokenError) { + console.warn('Cannot initialize client due error:', matrixTokenError); + return; + } + initalizeMatrixClient(matrixToken!); + }, [user, matrixToken, isMatrixTokenLoading, matrixTokenError]); + + const createRoom = React.useCallback( + async (title: string) => { + if (!client) { + throw new Error('Client should be specified'); + } + const { room_id: roomId } = await client.createRoom({ + preset: RoomPreset.PublicChat, + topic: title, + }); + return { roomId }; + }, + [client], + ); + + const sendMessage = React.useCallback( + async ({ roomId, message }: SendMessageInput) => { + if (!client) { + throw new Error('Client should be specified'); + } + if (!message.trim()) { + return; + } + + if (roomId) { + await client.sendEvent(roomId, EventType.RoomMessage, { + msgtype: MsgType.Text, + body: message, + }); + } + }, + [client], + ); + + const getRoomMessages = React.useCallback( + (roomId: string): Message[] | null => { + if (!client) { + throw new Error('Client should be specified'); + } + + const room = client.getRoom(roomId); + const messages = room + ? room + .getLiveTimeline() + .getEvents() + .filter((event) => event.getType() === EventType.RoomMessage) + .map((event) => ({ + id: event.getId()!, + sender: event.getSender()!, + content: event.getContent().body, + timestamp: new Date(event.getTs()), + })) + : null; + return messages; + }, + [client], + ); + + const joinRoom = React.useCallback( + async (roomId: string) => { + if (!client) { + throw new Error('Client should be specified'); + } + + try { + await client.joinRoom(roomId); + } catch (error) { + console.warn('Cannot join to room:', error); + } + }, + [client], + ); + + const registerRoomListener = React.useCallback( + (roomId: string, listener: RoomMessageListener) => { + if (!client) { + console.warn('Matrix client is not initialized'); + return; + } + unregisterRoomListerner(roomId); + const eventListener: MatrixEventListener = async ( + event: MatrixSdk.MatrixEvent, + ) => { + if ( + event.getRoomId() === roomId && + event.getType() === EventType.RoomMessage + ) { + const message: Message = { + id: event.getId()!, + sender: event.getSender()!, + content: event.getContent().body, + timestamp: new Date(event.getTs()), + }; + await listener(message); + } + }; + client.addListener(RoomEvent.Timeline, eventListener); + setRegisteredRoomListeners((prev) => [ + ...prev, + { roomId, listener: eventListener }, + ]); + }, + [client], + ); + + const unregisterRoomListerner = React.useCallback( + (roomId: string) => { + if (!client) { + console.warn('Matrix client is not initialized'); + return; + } + type Records = RoomMessageListenerRecord[]; + const { found, rest } = registeredRoomListeners.reduce( + (acc, item) => { + if (item.roomId === roomId) { + acc.found.push(item); + } else { + acc.rest.push(item); + } + return acc; + }, + { found: [] as Records, rest: [] as Records }, + ); + for (const item of found) { + if (item) { + client.removeListener(RoomEvent.Timeline, item.listener); + } + } + setRegisteredRoomListeners(rest); + }, + [registeredRoomListeners, client], + ); + + const value: MatrixContextType = { + client, + isMatrixAvailable, + isAuthenticated, + createRoom, + sendMessage, + getRoomMessages, + joinRoom, + registerRoomListener, + unregisterRoomListerner, + registeredRoomListeners, + }; + return ( + {children} + ); +}; + +export const useMatrix = () => { + const context = React.useContext(MatrixContext); + if (!context) { + throw new Error('useMatrix must be used within MatrixProvider'); + } + return context; +}; + +export const RoomEvent = MatrixSdk.RoomEvent; +export const EventType = MatrixSdk.EventType; +export const MsgType = MatrixSdk.MsgType; +export const RoomPreset = MatrixSdk.Preset; diff --git a/packages/core/src/matrix/index.ts b/packages/core/src/matrix/index.ts new file mode 100644 index 0000000000..70f3c79a50 --- /dev/null +++ b/packages/core/src/matrix/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './client'; diff --git a/packages/core/src/matrix/server/actions.ts b/packages/core/src/matrix/server/actions.ts new file mode 100644 index 0000000000..f0d0a1d274 --- /dev/null +++ b/packages/core/src/matrix/server/actions.ts @@ -0,0 +1,53 @@ +'use server'; + +import { db } from '@hypha-platform/storage-postgres'; +import { + CreateMatrixUserLinkInput, + GetAdminUserNameActionInput, + GetMatrixUserLinkActionInput, + UpdateEncryptedAccessTokenInput, +} from '../types'; +import { createMatrixUserLink, updateMatrixUserLink } from './mutations'; +import { findLinkByPrivyUserId, findAdminUserName } from './queries'; + +export async function createMatrixUserLinkAction( + data: CreateMatrixUserLinkInput, + { authToken }: { authToken?: string }, +) { + if (!authToken) { + throw new Error('authToken is required to create Matrix user link'); + } + return await createMatrixUserLink({ ...data }, { db }); +} + +export async function updateEncryptedAccessTokenAction( + data: UpdateEncryptedAccessTokenInput, + { authToken }: { authToken?: string }, +) { + if (!authToken) { + throw new Error( + 'authToken is required to update Matrix user link encrypted access token', + ); + } + return await updateMatrixUserLink(data, { db }); +} + +export async function getMatrixUserLinkAction( + data: GetMatrixUserLinkActionInput, + { authToken }: { authToken?: string }, +) { + if (!authToken) { + throw new Error('authToken is required to get Matrix user link'); + } + return await findLinkByPrivyUserId(data, { db }); +} + +export async function getAdminUserNameAction( + data: GetAdminUserNameActionInput, + { authToken }: { authToken?: string }, +) { + if (!authToken) { + throw new Error('authToken is required to get admin user name'); + } + return await findAdminUserName(data, { db }); +} diff --git a/packages/core/src/matrix/server/index.ts b/packages/core/src/matrix/server/index.ts new file mode 100644 index 0000000000..3a4c0ca04a --- /dev/null +++ b/packages/core/src/matrix/server/index.ts @@ -0,0 +1,4 @@ +export * from './queries'; +export * from './mutations'; +export * from './actions'; +export * from './web3'; diff --git a/packages/core/src/matrix/server/mutations.ts b/packages/core/src/matrix/server/mutations.ts new file mode 100644 index 0000000000..a244b57c17 --- /dev/null +++ b/packages/core/src/matrix/server/mutations.ts @@ -0,0 +1,72 @@ +import { matrixUserLinks } from '@hypha-platform/storage-postgres'; +import { DatabaseInstance } from '../../server'; +import { + CreateMatrixUserLinkInput, + UpdateEncryptedAccessTokenInput, +} from '../types'; +import { and, eq } from 'drizzle-orm'; + +export const createMatrixUserLink = async ( + { + privyUserId, + matrixUserId, + encryptedAccessToken, + deviceId, + environment, + }: CreateMatrixUserLinkInput, + { db }: { db: DatabaseInstance }, +) => { + if (!privyUserId) { + throw new Error('privyUserId is required to create Matrix user link'); + } + if (!matrixUserId) { + throw new Error('matrixUserId is required to create Matrix user link'); + } + if (!encryptedAccessToken) { + throw new Error( + 'encryptedAccessToken is required to create Matrix user link', + ); + } + const [newRecord] = await db + .insert(matrixUserLinks) + .values({ + privyUserId, + matrixUserId, + encryptedAccessToken, + deviceId, + environment, + }) + .returning(); + + if (!newRecord) { + throw new Error('Failed to create Matrix user link'); + } + + return newRecord; +}; + +export const updateMatrixUserLink = async ( + { + privyUserId, + environment, + encryptedAccessToken, + }: UpdateEncryptedAccessTokenInput, + { db }: { db: DatabaseInstance }, +) => { + const [updatedMatrixUserLink] = await db + .update(matrixUserLinks) + .set({ privyUserId, encryptedAccessToken }) + .where( + and( + eq(matrixUserLinks.environment, environment), + eq(matrixUserLinks.privyUserId, privyUserId), + ), + ) + .returning(); + + if (!updatedMatrixUserLink) { + throw new Error('Failed to update Matrix user link encrypted access token'); + } + + return updatedMatrixUserLink; +}; diff --git a/packages/core/src/matrix/server/queries.ts b/packages/core/src/matrix/server/queries.ts new file mode 100644 index 0000000000..1c0b0161f5 --- /dev/null +++ b/packages/core/src/matrix/server/queries.ts @@ -0,0 +1,63 @@ +import { MatrixUserLink } from '@hypha-platform/storage-postgres'; +import { DbConfig } from '../../server'; + +export const findLinkByPrivyUserId = async ( + { privyUserId, environment }: { privyUserId: string; environment: string }, + { db }: DbConfig, +): Promise => { + const response = await db.query.matrixUserLinks.findFirst({ + where: (matrixUserLinks, { eq, and }) => + and( + eq(matrixUserLinks.privyUserId, privyUserId), + eq(matrixUserLinks.environment, environment), + ), + }); + + if (!response) { + return null; + } + + return { + ...response, + }; +}; + +export const findLinkByMatrixUserId = async ( + { matrixUserId, environment }: { matrixUserId: string; environment: string }, + { db }: DbConfig, +): Promise => { + const response = await db.query.matrixUserLinks.findFirst({ + where: (matrixUserLinks, { eq, and }) => + and( + eq(matrixUserLinks.matrixUserId, matrixUserId), + eq(matrixUserLinks.environment, environment), + ), + }); + + if (!response) { + return null; + } + + return { + ...response, + }; +}; + +export const findAdminUserName = async ( + { baseName, environment }: { baseName: string; environment: string }, + { db }: DbConfig, +): Promise => { + const response = await db.query.matrixUserLinks.findFirst({ + where: (matrixUserLinks, { eq, and, like }) => + and( + eq(matrixUserLinks.environment, environment), + like(matrixUserLinks.privyUserId, `%${baseName}%`), + ), + }); + + if (!response) { + return null; + } + + return response.privyUserId; +}; diff --git a/packages/core/src/matrix/server/web3/get-link-by-matrix-user-id.ts b/packages/core/src/matrix/server/web3/get-link-by-matrix-user-id.ts new file mode 100644 index 0000000000..088627ce36 --- /dev/null +++ b/packages/core/src/matrix/server/web3/get-link-by-matrix-user-id.ts @@ -0,0 +1,42 @@ +'use server'; + +import { db } from '@hypha-platform/storage-postgres'; +import { MatrixUserLink } from '../../types'; +import { findLinkByMatrixUserId } from '../queries'; + +interface GetLinkByMatrixUserIdInput { + matrixUserId: string; + environment: string; +} + +export async function getLinkByMatrixUserId({ + matrixUserId, + environment, +}: GetLinkByMatrixUserIdInput): Promise { + try { + const userLink = await findLinkByMatrixUserId( + { + matrixUserId, + environment, + }, + { db }, + ); + + if (!userLink) { + return null; + } + + const { deviceId, refreshToken, tokenExpiresAt, ...rest } = userLink; + + return { + deviceId: deviceId ?? undefined, + refreshToken: refreshToken ?? undefined, + tokenExpiresAt: tokenExpiresAt ?? undefined, + ...rest, + }; + } catch (error) { + throw new Error('Failed to get Matrix user ID from Matrix user link', { + cause: error instanceof Error ? error : new Error(String(error)), + }); + } +} diff --git a/packages/core/src/matrix/server/web3/get-link-by-privy-user-id.ts b/packages/core/src/matrix/server/web3/get-link-by-privy-user-id.ts new file mode 100644 index 0000000000..9b786553e3 --- /dev/null +++ b/packages/core/src/matrix/server/web3/get-link-by-privy-user-id.ts @@ -0,0 +1,40 @@ +import { db } from '@hypha-platform/storage-postgres'; +import { MatrixUserLink } from '../../types'; +import { findLinkByPrivyUserId } from '../queries'; + +interface GetLinkByPrivyUserIdInput { + privyUserId: string; + environment: string; +} + +export async function getLinkByPrivyUserId({ + privyUserId, + environment, +}: GetLinkByPrivyUserIdInput): Promise { + try { + const userLink = await findLinkByPrivyUserId( + { + privyUserId, + environment, + }, + { db }, + ); + + if (!userLink) { + return null; + } + + const { deviceId, refreshToken, tokenExpiresAt, ...rest } = userLink; + + return { + deviceId: deviceId ?? undefined, + refreshToken: refreshToken ?? undefined, + tokenExpiresAt: tokenExpiresAt ?? undefined, + ...rest, + }; + } catch (error) { + throw new Error('Failed to get Privy user ID from Matrix user link', { + cause: error instanceof Error ? error : new Error(String(error)), + }); + } +} diff --git a/packages/core/src/matrix/server/web3/index.ts b/packages/core/src/matrix/server/web3/index.ts new file mode 100644 index 0000000000..804f9ea926 --- /dev/null +++ b/packages/core/src/matrix/server/web3/index.ts @@ -0,0 +1 @@ +export * from './get-link-by-privy-user-id'; diff --git a/packages/core/src/matrix/types.ts b/packages/core/src/matrix/types.ts new file mode 100644 index 0000000000..5ee2d312fc --- /dev/null +++ b/packages/core/src/matrix/types.ts @@ -0,0 +1,42 @@ +export type MatrixUserLink = { + id: number; + privyUserId: string; + matrixUserId: string; + encryptedAccessToken: string; + deviceId?: string; + createdAt: Date; + updatedAt: Date; + refreshToken?: string; + tokenExpiresAt?: Date; +}; + +export interface CreateMatrixUserLinkInput { + environment: string; + privyUserId: string; + matrixUserId: string; + encryptedAccessToken: string; + deviceId?: string; +} + +export interface UpdateEncryptedAccessTokenInput { + privyUserId: string; + encryptedAccessToken: string; + environment: string; +} + +export interface GetMatrixUserLinkActionInput { + environment: string; + privyUserId: string; +} + +export interface GetAdminUserNameActionInput { + baseName: string; + environment: string; +} + +export interface Message { + id: string; + sender: string; + content: string; + timestamp: Date; +} diff --git a/packages/core/src/people/client/hooks/index.ts b/packages/core/src/people/client/hooks/index.ts index 1d286932b0..7154de9265 100644 --- a/packages/core/src/people/client/hooks/index.ts +++ b/packages/core/src/people/client/hooks/index.ts @@ -1,3 +1,5 @@ export { useJwt } from './useJwt'; export { useMe } from './use-me'; export { usePendingRewards } from './usePendingRewards'; +export { usePersonById } from './usePersonById'; +export { usePersonBySub } from './usePersonBySub'; diff --git a/packages/core/src/people/client/hooks/usePersonById.ts b/packages/core/src/people/client/hooks/usePersonById.ts new file mode 100644 index 0000000000..20c1068f8e --- /dev/null +++ b/packages/core/src/people/client/hooks/usePersonById.ts @@ -0,0 +1,17 @@ +'use client'; + +import React from 'react'; +import { getPersonById } from '../../server/actions'; +import { useJwt } from './useJwt'; +import useSWR from 'swr'; + +export const usePersonById = ({ id }: { id?: number }) => { + const { jwt } = useJwt(); + + const { data: person, isLoading } = useSWR( + id && jwt ? [id, jwt] : null, + async ([id]) => getPersonById({ id }, { authToken: jwt ?? undefined }), + ); + + return { person, isLoading }; +}; diff --git a/packages/core/src/people/client/hooks/usePersonBySub.ts b/packages/core/src/people/client/hooks/usePersonBySub.ts new file mode 100644 index 0000000000..e11f614f40 --- /dev/null +++ b/packages/core/src/people/client/hooks/usePersonBySub.ts @@ -0,0 +1,16 @@ +'use client'; + +import { getPersonBySub } from '../../server/actions'; +import { useJwt } from './useJwt'; +import useSWR from 'swr'; + +export const usePersonBySub = ({ sub }: { sub?: string }) => { + const { jwt } = useJwt(); + + const { data: person, isLoading } = useSWR( + sub && jwt ? [sub, jwt] : null, + async ([sub]) => getPersonBySub({ sub }, { authToken: jwt ?? undefined }), + ); + + return { person, isLoading }; +}; diff --git a/packages/core/src/people/server/actions.ts b/packages/core/src/people/server/actions.ts index 8b921f801a..6c40f00a83 100644 --- a/packages/core/src/people/server/actions.ts +++ b/packages/core/src/people/server/actions.ts @@ -1,12 +1,37 @@ 'use server'; import { updatePerson } from './mutations'; -import { EditPersonInput } from '../types'; +import { + EditPersonInput, + GetPersonByIdInput, + GetPersonBySubInput, +} from '../types'; import { db } from '@hypha-platform/storage-postgres'; +import { findPersonById, findPersonBySub } from './queries'; export async function editPersonAction( data: EditPersonInput, { authToken }: { authToken?: string }, ) { - return updatePerson(data, { db }); + return await updatePerson(data as any, { db }); +} + +export async function getPersonById( + { id }: GetPersonByIdInput, + { authToken }: { authToken?: string }, +) { + if (!id) { + return null; + } + return findPersonById({ id }, { db }); +} + +export async function getPersonBySub( + { sub }: GetPersonBySubInput, + { authToken }: { authToken?: string }, +) { + if (!sub) { + return null; + } + return findPersonBySub({ sub }, { db }); } diff --git a/packages/core/src/people/server/queries.ts b/packages/core/src/people/server/queries.ts index 744a32e560..4cad1dbd68 100644 --- a/packages/core/src/people/server/queries.ts +++ b/packages/core/src/people/server/queries.ts @@ -161,6 +161,23 @@ export const findPersonByWeb3Address = async ( return mapToDomainPerson(person); }; +export type FindPersonBySubInput = { + sub: string; +}; +export const findPersonBySub = async ( + { sub }: FindPersonBySubInput, + { db }: DbConfig, +) => { + const [person] = await db + .select() + .from(people) + .where(eq(people.sub, sub)) + .limit(1); + if (!person) return null; + + return mapToDomainPerson(person); +}; + export type FindPeopleByWeb3AddressesInput = { addresses: string[]; }; diff --git a/packages/core/src/people/types.ts b/packages/core/src/people/types.ts index 48fca7e59c..591efd5fbd 100644 --- a/packages/core/src/people/types.ts +++ b/packages/core/src/people/types.ts @@ -16,6 +16,14 @@ export interface Person { updatedAt: Date; } +export interface GetPersonByIdInput { + id?: number; +} + +export interface GetPersonBySubInput { + sub?: string; +} + export interface EditPersonInput { avatarUrl?: string; name?: string; diff --git a/packages/core/src/server.ts b/packages/core/src/server.ts index 438d3c19b0..83a6cc67e1 100644 --- a/packages/core/src/server.ts +++ b/packages/core/src/server.ts @@ -10,3 +10,7 @@ export * from './space/server'; export * from './space/types'; export * from './events/server'; export * from './transaction/server'; +export * from './coherence/server'; +export * from './coherence/types'; +export * from './matrix/server'; +export * from './matrix/types'; diff --git a/packages/epics/package.json b/packages/epics/package.json index 341d4c4807..2b03c8fb72 100644 --- a/packages/epics/package.json +++ b/packages/epics/package.json @@ -11,12 +11,14 @@ "@hypha-platform/config-typescript": "workspace:*" }, "dependencies": { + "@ai-sdk/react": "^3.0.107", "@hypha-platform/authentication": "workspace:*", - "@hypha-platform/ui": "workspace:*", - "@hypha-platform/ui-utils": "workspace:*", "@hypha-platform/core": "workspace:*", "@hypha-platform/i18n": "workspace:*", - "@hypha-platform/notifications": "workspace:*" + "@hypha-platform/notifications": "workspace:*", + "@hypha-platform/ui": "workspace:*", + "@hypha-platform/ui-utils": "workspace:*", + "ai": "^6.0.105" }, "exports": { ".": "./src/index.ts" diff --git a/packages/epics/src/coherence/components/chat-detail.tsx b/packages/epics/src/coherence/components/chat-detail.tsx new file mode 100644 index 0000000000..519e4ffb87 --- /dev/null +++ b/packages/epics/src/coherence/components/chat-detail.tsx @@ -0,0 +1,85 @@ +import { Text } from '@radix-ui/themes'; +import { MarkdownSuspense, Separator, Skeleton } from '@hypha-platform/ui'; +import { formatDate } from '@hypha-platform/ui-utils'; +import { Coherence } from '@hypha-platform/core/server'; +import { ChatRoom } from './chat-room'; +import { ChatHead } from './chat-head'; +import { ButtonClose } from '../../common'; +import { CreatorType } from '../../proposals'; +import { ChatMessageInput } from './chat-message-input'; +import React from 'react'; +import { + useCoherenceMutationsWeb2Rsc, + useJwt, +} from '@hypha-platform/core/client'; + +type ChatDetailProps = { + closeUrl: string; + creator?: CreatorType; + conversation?: Coherence; + isLoading: boolean; +}; + +export const ChatDetail = ({ + closeUrl, + creator, + conversation, + isLoading, +}: ChatDetailProps) => { + const { jwt: authToken } = useJwt(); + const { updateCoherenceBySlug } = useCoherenceMutationsWeb2Rsc(authToken); + + React.useEffect(() => { + //TODO: improve compute views + if (!conversation) { + return; + } + const { slug, views = 0 } = conversation; + updateCoherenceBySlug({ slug, views: views + 1 }); + }, [conversation]); + + return ( +
+
+
+
+ +
+ +
+ +
+
+
+
+ + {conversation?.title} + + + {conversation?.description} + +
+ + +
+
+ +
+
+ ); +}; diff --git a/packages/epics/src/coherence/components/chat-head.tsx b/packages/epics/src/coherence/components/chat-head.tsx new file mode 100644 index 0000000000..5d94e18813 --- /dev/null +++ b/packages/epics/src/coherence/components/chat-head.tsx @@ -0,0 +1,52 @@ +'use client'; + +import { Text } from '@radix-ui/themes'; +import { Skeleton } from '@hypha-platform/ui'; +import { PersonAvatar } from '../../people/components/person-avatar'; +import { ChatCreatorType } from '../types'; + +export type ChatHeadProps = { + creator?: ChatCreatorType; + createDate?: string; + isLoading?: boolean; +}; + +export const ChatHead = ({ + creator, + createDate, + isLoading = false, +}: ChatHeadProps) => { + const displayName = + creator?.type === 'space' + ? creator.name + : `${creator?.name} ${creator?.surname}`.trim(); + + return ( +
+
+ +
+
+ +
+ {displayName} +
+
+
+ + + {createDate && <>Created on {createDate}} + + +
+
+
+
+
+ ); +}; diff --git a/packages/epics/src/coherence/components/chat-message-input.tsx b/packages/epics/src/coherence/components/chat-message-input.tsx new file mode 100644 index 0000000000..8b9d834644 --- /dev/null +++ b/packages/epics/src/coherence/components/chat-message-input.tsx @@ -0,0 +1,105 @@ +'use client'; + +import React from 'react'; +import { + useCoherenceMutationsWeb2Rsc, + useJwt, + useMatrix, +} from '@hypha-platform/core/client'; +import { Button, ConfirmDialog, Input } from '@hypha-platform/ui'; +import { PaperPlaneIcon } from '@radix-ui/react-icons'; +import { useRouter } from 'next/navigation'; + +export const ChatMessageInput = ({ + roomId, + coherenceSlug, + closeUrl, +}: { + roomId: string; + coherenceSlug: string; + closeUrl: string; +}) => { + const { client, sendMessage: sendMatrixMessage } = useMatrix(); + const [input, setInput] = React.useState(''); + const { jwt: authToken } = useJwt(); + const { updateCoherenceBySlug } = useCoherenceMutationsWeb2Rsc(authToken); + const router = useRouter(); + + const sendMessage = React.useCallback(async () => { + try { + await sendMatrixMessage({ roomId, message: input.trim() }); + setInput(''); + } catch (error) { + console.warn(error); + } + }, [client, input, roomId, sendMatrixMessage]); + + const handleArchive = React.useCallback(async () => { + try { + await updateCoherenceBySlug({ slug: coherenceSlug, archived: true }); + router.push(closeUrl); + } catch (error) { + console.warn('Could not archive conversation:', error); + } + }, [coherenceSlug, router, closeUrl]); + + return ( +
+
+ + + + +
+
+
+ { + sendMessage(); + }} + > + + + } + onKeyDown={(e) => { + if (e.key === 'Enter') { + sendMessage(); + } + }} + onChange={(e) => setInput(e.target.value)} + /> +
+
+
+ ); +}; diff --git a/packages/epics/src/coherence/components/chat-message.container.tsx b/packages/epics/src/coherence/components/chat-message.container.tsx new file mode 100644 index 0000000000..46bb30e5ae --- /dev/null +++ b/packages/epics/src/coherence/components/chat-message.container.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { Separator } from '@hypha-platform/ui'; +import { ChatMessage } from './chat-message'; +import { Message } from '@hypha-platform/core/client'; + +type ChatMessageContainerProps = { + messages: Message[]; + isLoading: boolean; +}; + +export const ChatMessageContainer = ({ + messages, + isLoading, +}: ChatMessageContainerProps) => { + return ( +
+ {isLoading ? ( + <> + + + + + + + ) : ( + messages.map((msg, index) => ( +
+ {index !== 0 && } + +
+ )) + )} +
+ ); +}; diff --git a/packages/epics/src/coherence/components/chat-message.tsx b/packages/epics/src/coherence/components/chat-message.tsx new file mode 100644 index 0000000000..07de07efd9 --- /dev/null +++ b/packages/epics/src/coherence/components/chat-message.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { + Message, + usePersonBySub, + useUserPrivyIdByMatrixId, +} from '@hypha-platform/core/client'; +import { PersonAvatar } from '../../people/components/person-avatar'; +import React from 'react'; +import { Skeleton } from '@hypha-platform/ui'; +import { formatDate } from '@hypha-platform/ui-utils'; + +type ChatMessageProps = { + message: Message; + isLoading: boolean; +}; + +export const ChatMessage = ({ message, isLoading }: ChatMessageProps) => { + const { privyUserId, isLoading: isLoadingPrivyUserId } = + useUserPrivyIdByMatrixId({ matrixUserId: message.sender }); + const { person, isLoading: isLoadingPerson } = usePersonBySub({ + sub: privyUserId, + }); + const displayName = React.useMemo(() => { + if (isLoadingPerson || !person) { + return ''; + } + return `${person.name} ${person.surname}`; + }, [isLoadingPerson, person]); + return ( +
+
+ +
+
+
+ +
+ {displayName} +
+
+ +
+ {formatDate(message.timestamp, true)} +
+
+
+ +
+ {message.content} +
+
+
+
+ ); +}; diff --git a/packages/epics/src/coherence/components/chat-room.tsx b/packages/epics/src/coherence/components/chat-room.tsx new file mode 100644 index 0000000000..d4efc2f028 --- /dev/null +++ b/packages/epics/src/coherence/components/chat-room.tsx @@ -0,0 +1,101 @@ +'use client'; + +import { + Message, + useCoherenceMutationsWeb2Rsc, + useJwt, + useMatrix, +} from '@hypha-platform/core/client'; +import React from 'react'; +import { ChatMessageContainer } from './chat-message.container'; + +const scrollToSection = (id: string) => { + const element = document.getElementById(id); + element?.scrollIntoView({ behavior: 'smooth' }); +}; + +export const ChatRoom = ({ + roomId, + isLoading, + slug, +}: { + roomId: string; + isLoading: boolean; + slug: string; +}) => { + const { + isMatrixAvailable, + getRoomMessages, + joinRoom, + registerRoomListener, + unregisterRoomListerner, + } = useMatrix(); + const { jwt: authToken } = useJwt(); + const bottomId = React.useId(); + const { updateCoherenceBySlug } = useCoherenceMutationsWeb2Rsc(authToken); + const [messages, setMessages] = React.useState([]); + const [isMessagesLoading, setIsMessagesLoading] = React.useState(false); + + React.useEffect(() => { + if (!isMatrixAvailable) { + console.log('Matrix client is not initialized'); + return; + } + + const register = async (roomId: string) => { + try { + await joinRoom(roomId); + + registerRoomListener(roomId, async (message: Message) => { + setIsMessagesLoading(true); + setMessages((prev) => [...prev, message]); + setIsMessagesLoading(false); + }); + + setIsMessagesLoading(true); + const msgs = getRoomMessages(roomId); + if (msgs) { + setMessages(msgs); + } + setIsMessagesLoading(false); + } catch (error) { + unregisterRoomListerner(roomId); + setIsMessagesLoading(false); + } + }; + + register(roomId); + + return () => { + unregisterRoomListerner(roomId); + }; + }, [isMatrixAvailable, roomId]); + + React.useEffect(() => { + if (!isMatrixAvailable || !roomId || !slug) { + return; + } + updateCoherenceBySlug({ slug, messages: messages.length }).catch( + (error) => { + console.warn('Error due update conversation:', error); + }, + ); + }, [isMatrixAvailable, roomId, messages, slug]); + + React.useEffect(() => { + if (!isMatrixAvailable || !messages) { + return; + } + scrollToSection(`message-list-bottom-${bottomId}`); + }, [isMatrixAvailable, messages, bottomId]); + + return ( +
+ +
+
+ ); +}; diff --git a/packages/epics/src/coherence/components/coherence-block.tsx b/packages/epics/src/coherence/components/coherence-block.tsx new file mode 100644 index 0000000000..ccc471ca1b --- /dev/null +++ b/packages/epics/src/coherence/components/coherence-block.tsx @@ -0,0 +1,68 @@ +'use client'; + +import { useAuthentication } from '@hypha-platform/authentication'; +import { Empty } from '../../common/empty'; +import { useFindCoherences, useSpaceBySlug } from '@hypha-platform/core/client'; +import { Locale } from '@hypha-platform/i18n'; +import React from 'react'; +import { CoherenceOrder } from '../types'; +import { Coherence, DirectionType } from '@hypha-platform/core/client'; +import { SignalSection } from './signal-section'; +import { ConversationSection } from './conversation-section'; + +type CoherenceBlockProps = { + lang: Locale; + spaceSlug: string; + order?: CoherenceOrder; +}; + +export function CoherenceBlock({ + lang, + spaceSlug, + order, +}: CoherenceBlockProps) { + const [hideArchived, setHideArchived] = React.useState(true); + const { isAuthenticated } = useAuthentication(); + const { space, isLoading: isSpaceLoading } = useSpaceBySlug(spaceSlug); + const { + coherences: signals, + isLoading: isSignalsLoading, + refresh: refreshSignals, + } = useFindCoherences({ + spaceId: space?.id, + includeArchived: !hideArchived, + orderBy: order, + }); + + const refresh = React.useCallback(async () => { + await refreshSignals(); + }, [refreshSignals]); + + const chatBasePath = React.useMemo( + () => `/${lang}/dho/${spaceSlug}/coherence/chat`, + [lang, spaceSlug], + ); + + return ( +
+ {isAuthenticated ? ( + <> + + + ) : ( + +

Please, sign in to see signals and conversations

+
+ )} +
+ ); +} diff --git a/packages/epics/src/coherence/components/coherence-priority-button.tsx b/packages/epics/src/coherence/components/coherence-priority-button.tsx new file mode 100644 index 0000000000..a221728d58 --- /dev/null +++ b/packages/epics/src/coherence/components/coherence-priority-button.tsx @@ -0,0 +1,55 @@ +import { CardButton, CardButtonProps } from '../../common/card-button'; +import { cn } from '@hypha-platform/ui-utils'; +import { Circle } from 'lucide-react'; + +export interface CoherencePriorityButtonProps extends CardButtonProps { + onClick: () => void; +} + +export const CoherencePriorityButton = ({ + title, + description, + colorVariant, + selected, + className, + onClick, +}: CoherencePriorityButtonProps) => { + const textClass = ((variant) => { + switch (variant) { + case 'error': + return 'text-error-9'; + case 'warn': + return 'text-warning-11'; + case 'success': + return 'text-success-11'; + default: + return 'text-neutral-9'; + } + })(colorVariant); + const textColor = ((variant) => { + switch (variant) { + case 'error': + return 'var(--error-9)'; + case 'warn': + return 'var(--warning-11)'; + case 'success': + return 'var(--success-11)'; + default: + return 'var(--neutral-9)'; + } + })(colorVariant); + return ( + +
+ + {title} + {description} +
+
+ ); +}; diff --git a/packages/epics/src/coherence/components/coherence-type-button.tsx b/packages/epics/src/coherence/components/coherence-type-button.tsx new file mode 100644 index 0000000000..7cc39c1e1a --- /dev/null +++ b/packages/epics/src/coherence/components/coherence-type-button.tsx @@ -0,0 +1,63 @@ +import { CardButton, CardButtonProps } from '../../common/card-button'; +import { DynamicIcon, LucideReactIcon } from '@hypha-platform/ui'; +import { cn } from '@hypha-platform/ui-utils'; +import { CheckIcon, TicketIcon } from 'lucide-react'; + +export interface CoherenceTypeButtonProps extends CardButtonProps { + icon: LucideReactIcon; + onClick: () => void; +} + +export const CoherenceTypeButton = ({ + icon, + title, + description, + colorVariant, + selected, + className, + onClick, +}: CoherenceTypeButtonProps) => { + const textColor = ((variant) => { + switch (variant) { + case 'accent': + return 'text-accent-9'; + case 'error': + return 'text-error-9'; + case 'warn': + return 'text-warning-11'; + case 'success': + return 'text-success-11'; + case 'neutral': + return 'text-neutral-9'; + case 'tension': + return 'text-tension-10'; + case 'insight': + return 'text-insight-9'; + default: + return 'text-neutral-9'; + } + })(colorVariant); + return ( + +
+
+ +
+
+ {title} + + {description} + +
+
+ {selected && } +
+
+
+ ); +}; diff --git a/packages/epics/src/coherence/components/conversation-card.tsx b/packages/epics/src/coherence/components/conversation-card.tsx new file mode 100644 index 0000000000..603177b62b --- /dev/null +++ b/packages/epics/src/coherence/components/conversation-card.tsx @@ -0,0 +1,209 @@ +import { + Button, + Card, + CardContent, + CardTitle, + ConfirmDialog, + Input, + Skeleton, +} from '@hypha-platform/ui'; +import { PersonLabel } from '../../people/components/person-label'; +import { stripDescription, stripMarkdown } from '@hypha-platform/ui-utils'; +import { + ChatBubbleIcon, + EyeOpenIcon, + PaperPlaneIcon, +} from '@radix-ui/react-icons'; +import React from 'react'; +import { + Coherence, + useCoherenceMutationsWeb2Rsc, + useJwt, + useMatrix, + usePersonById, +} from '@hypha-platform/core/client'; + +type ConversationCardProps = { + isLoading: boolean; + refresh: () => Promise; +}; + +export const ConversationCard: React.FC = ({ + isLoading, + title, + description, + creatorId, + archived, + roomId, + slug, + views = 0, + messages = 0, + refresh, +}) => { + const [message, setMessage] = React.useState(''); + const { isLoading: isPersonLoading, person: creator } = usePersonById({ + id: creatorId, + }); + const { sendMessage: sendMatrixMessage } = useMatrix(); + const { jwt: authToken } = useJwt(); + const { updateCoherenceBySlug } = useCoherenceMutationsWeb2Rsc(authToken); + + const sendMessage = React.useCallback(async () => { + console.log('Send message into chat:', message); + await sendMatrixMessage({ roomId: roomId!, message }); + setMessage(''); + }, [message, roomId, sendMatrixMessage]); + + const proposeAgreement = () => { + console.log('Propose agreement'); + //TODO + }; + + const handleUnarchive = React.useCallback(async () => { + console.log('Unarchive conversation'); + try { + await updateCoherenceBySlug({ slug, archived: false }); + await refresh(); + } catch (error) { + console.warn('Could not unarchive conversation:', error); + } + }, [slug, refresh]); + + return ( + + +
+ + {creator && } + + + {title} + +
+
+ +
+ {stripDescription( + stripMarkdown(description, { + orderedListMarkers: false, + unorderedListMarkers: false, + }), + )} +
+
+
+
+
+
+
+ +
+ +
+
{views}
+
+
+
+ +
+ +
+
{messages}
+
+
+
+
+
+
+ {!archived && ( +
+ { + e.stopPropagation(); + e.preventDefault(); + sendMessage(); + }} + > + + + } + onClick={(e) => { + e.stopPropagation(); + e.preventDefault(); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + sendMessage(); + } + }} + onChange={(e) => setMessage(e.target.value)} + /> +
+ )} +
+ {archived ? ( +
{ + e.stopPropagation(); + e.preventDefault(); + }} + > + + + +
+ ) : ( + + )} +
+
+
+
+ ); +}; diff --git a/packages/epics/src/coherence/components/conversation-grid.container.tsx b/packages/epics/src/coherence/components/conversation-grid.container.tsx new file mode 100644 index 0000000000..aa3d49f59e --- /dev/null +++ b/packages/epics/src/coherence/components/conversation-grid.container.tsx @@ -0,0 +1,41 @@ +import { Coherence, Order } from '@hypha-platform/core/client'; +import { ConversationGrid } from './conversation-grid'; + +type ConversationGridContainerProps = { + basePath: string; + pagination: { + page: number; + firstPageSize: number; + pageSize: number; + searchTerm?: string; + order?: Order; + }; + conversations: Coherence[]; + refresh: () => Promise; +}; + +export const ConversationGridContainer = ({ + basePath, + pagination, + conversations, + refresh, +}: ConversationGridContainerProps) => { + const { page, firstPageSize, pageSize } = pagination; + const startIndex = page <= 1 ? 0 : firstPageSize + (page - 2) * pageSize; + const endIndex = Math.min( + conversations.length, + page < 1 ? 0 : page === 1 ? firstPageSize : startIndex + pageSize, + ); + const paginatedConversations = conversations.slice(startIndex, endIndex); + + return ( + ({ + ...conversation, + }))} + refresh={refresh} + /> + ); +}; diff --git a/packages/epics/src/coherence/components/conversation-grid.tsx b/packages/epics/src/coherence/components/conversation-grid.tsx new file mode 100644 index 0000000000..d050be290f --- /dev/null +++ b/packages/epics/src/coherence/components/conversation-grid.tsx @@ -0,0 +1,44 @@ +'use client'; + +import Link from 'next/link'; +import { ConversationCard } from './conversation-card'; +import { Coherence } from '@hypha-platform/core/client'; + +type ConversationGridProps = { + isLoading: boolean; + basePath: string; + conversations: Coherence[]; + refresh: () => Promise; +}; + +export function ConversationGrid({ + isLoading = true, + basePath, + conversations, + refresh, +}: ConversationGridProps) { + return ( +
+ {conversations.map((conversation, index) => + conversation.archived ? ( + + ) : ( + + + + ), + )} +
+ ); +} diff --git a/packages/epics/src/coherence/components/conversation-section.tsx b/packages/epics/src/coherence/components/conversation-section.tsx new file mode 100644 index 0000000000..228edbcc67 --- /dev/null +++ b/packages/epics/src/coherence/components/conversation-section.tsx @@ -0,0 +1,183 @@ +'use client'; + +import { FC } from 'react'; +import { Text } from '@radix-ui/themes'; +import { + Checkbox, + Combobox, + SectionFilter, + SectionLoadMore, +} from '@hypha-platform/ui'; +import { COHERENCE_ORDERS, CoherenceOrder } from '../types'; +import { ConversationGridContainer } from './conversation-grid.container'; +import { useConversationsSection } from '../hooks/use-conversations-section'; +import { Empty } from '../../common'; +import { Coherence, DirectionType } from '@hypha-platform/core/client'; +import React from 'react'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; + +type ConversationSectionProps = { + basePath: string; + conversations: Coherence[]; + label?: string; + hasSearch?: boolean; + isLoading: boolean; + firstPageSize?: number; + pageSize?: number; + hideArchived: boolean; + order?: CoherenceOrder; + setHideArchived: (checked: boolean) => void; + refresh: () => Promise; +}; + +const orderOptions: { + value: CoherenceOrder; + label: string; + searchText: string; +}[] = [ + { + value: 'mostviews', + label: 'Most Views', + searchText: 'Most Members', + }, + { + value: 'mostmessages', + label: 'Most Messages', + searchText: 'Most Agreements', + }, + { + value: 'mostrecent', + label: 'Most Recent', + searchText: 'Most Recent', + }, +]; + +export const ConversationSection: FC = ({ + basePath, + conversations, + label, + hasSearch = false, + isLoading, + firstPageSize = 3, + pageSize = 3, + hideArchived, + order = 'mostrecent', + setHideArchived, + refresh, +}) => { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const { replace } = useRouter(); + + const { + pages, + loadMore, + pagination, + onUpdateSearch, + searchTerm, + filteredConversations, + } = useConversationsSection({ + conversations, + firstPageSize, + pageSize, + }); + + const setOrder = React.useCallback( + (rawOrder: string) => { + const params = new URLSearchParams(searchParams); + if (rawOrder) { + const order = COHERENCE_ORDERS.includes(rawOrder as CoherenceOrder) + ? (rawOrder as CoherenceOrder) + : 'mostrecent'; + params.set('order_conv', order); + } else { + params.delete('order_conv'); + } + replace(`${pathname}?${params.toString()}`); + }, + [searchParams, pathname, replace], + ); + + return ( +
+ +
+
+ +
+
+ { + setHideArchived(value === true); + }} + disabled={isLoading} + /> + +
+
+ + {pagination?.totalPages === 0 ? ( + +

List is empty

+
+ ) : ( +
+ {Array.from({ length: pages }).map((_, index) => ( + + ))} +
+ )} + {pagination?.totalPages === 0 ? null : ( + + + {pagination?.totalPages === pages ? 'No more' : 'Load more'} + + + )} +
+ ); +}; diff --git a/packages/epics/src/coherence/components/create-signal-form.tsx b/packages/epics/src/coherence/components/create-signal-form.tsx new file mode 100644 index 0000000000..f16e29bfb9 --- /dev/null +++ b/packages/epics/src/coherence/components/create-signal-form.tsx @@ -0,0 +1,353 @@ +'use client'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { + Button, + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, + Input, + LoadingBackdrop, + LucideReactIcon, + MultiSelect, + RequirementMark, + RichTextEditor, +} from '@hypha-platform/ui'; +import { Text } from '@radix-ui/themes'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { + COHERENCE_PRIORITY_OPTIONS, + COHERENCE_TAGS, + COHERENCE_TYPE_OPTIONS, + CoherenceType, + schemaCreateCoherenceForm, + useCoherenceMutationsWeb2Rsc, + useJwt, + useMatrix, + useMe, +} from '@hypha-platform/core/client'; +import React from 'react'; +import { useScrollToErrors } from '../../hooks'; +import { useRouter } from 'next/navigation'; +import { CoherenceTypeButton } from './coherence-type-button'; +import { CoherencePriorityButton } from './coherence-priority-button'; +import { ButtonClose } from '../../common/button-close'; +import { CardButtonColorVariant } from '../../common/card-button'; + +type FormValues = z.infer; + +interface CreateSignalFormProps { + spaceId: number; + successfulUrl: string; + closeUrl?: string; +} + +export const CreateSignalForm = ({ + spaceId, + successfulUrl, + closeUrl, +}: CreateSignalFormProps) => { + const { person } = useMe(); + const { jwt: authToken } = useJwt(); + const router = useRouter(); + const { + createCoherence, + isCreatingCoherence, + createdCoherence, + errorCreateCoherenceMutation, + resetCreateCoherenceMutation, + updateCoherenceBySlug, + isUpdatingCoherence, + } = useCoherenceMutationsWeb2Rsc(authToken); + const { isMatrixAvailable, createRoom } = useMatrix(); + + const progress = React.useMemo(() => { + return isCreatingCoherence ? 50 : createdCoherence ? 100 : 0; + }, [isCreatingCoherence, createdCoherence]); + + const formRef = React.useRef(null); + const form = useForm({ + resolver: zodResolver(schemaCreateCoherenceForm), + defaultValues: { + title: '', + description: '', + creatorId: person?.id, + spaceId, + archived: false, + }, + }); + + useScrollToErrors(form, formRef); + + const typeOptions = React.useMemo(() => { + const computeColor = (colorVariant: string) => { + return `var(--${colorVariant}-10)`; + }; + return COHERENCE_TYPE_OPTIONS.map( + ({ icon, title, description, type, colorVariant }) => ({ + icon: icon as LucideReactIcon, + title, + description, + type, + colorVariant: colorVariant as CardButtonColorVariant, + titleColor: computeColor(colorVariant), + }), + ); + }, []); + + const priorityOptions = React.useMemo(() => { + return COHERENCE_PRIORITY_OPTIONS.map( + ({ title, priority, description, colorVariant }) => ({ + title, + priority, + description, + colorVariant: colorVariant as CardButtonColorVariant, + }), + ); + }, []); + + const tagOptions = React.useMemo(() => { + return COHERENCE_TAGS.map((type) => ({ + value: type, + label: type, + })); + }, []); + + React.useEffect(() => { + const { isDirty } = form.getFieldState('creatorId'); + if (!isDirty && person?.id) { + form.setValue('creatorId', person.id, { + shouldDirty: false, + shouldTouch: false, + shouldValidate: true, + }); + } + }, [person, form]); + + React.useEffect(() => { + const { isDirty } = form.getFieldState('spaceId'); + if (!isDirty && spaceId) { + form.setValue('spaceId', spaceId, { + shouldDirty: false, + shouldTouch: false, + shouldValidate: true, + }); + } + }, [spaceId, form]); + + const handleCreate = React.useCallback( + async (data: FormValues) => { + console.log('Start Conversation'); + if (!isMatrixAvailable) { + console.warn( + 'Cannot create conversation since Matrix client is unavailable', + ); + return; + } + try { + const coherence = await createCoherence({ ...data }); + const { roomId } = await createRoom(coherence.title); + await updateCoherenceBySlug({ slug: coherence.slug!, roomId }); + } catch (error) { + console.warn('Could not create conversation:', error); + } + router.push(successfulUrl); + }, + [ + createRoom, + updateCoherenceBySlug, + isMatrixAvailable, + router, + successfulUrl, + ], + ); + + const handleInvalid = async (err?: any) => { + console.log('form errors:', err); + }; + + return ( + +
Ouh Snap. There was an error
+ +
+ ) : ( +
Creating new signal
+ ) + } + > +
+ +
+
+
+
+ +
+
+
+
+
+ ( + + + } + {...field} + /> + + + + + )} + /> + ( + +
+ + Type + + + + {typeOptions && + typeOptions.map((option, index) => { + return ( + { + form.setValue( + 'type', + option.type as CoherenceType, + { + shouldDirty: true, + }, + ); + }} + /> + ); + })} + + +
+ +
+ )} + /> + ( + +
+ + Priority + + + + {priorityOptions && + priorityOptions.map((option, index) => { + return ( + { + form.setValue('priority', option.priority, { + shouldDirty: true, + }); + }} + /> + ); + })} + + +
+ +
+ )} + /> + ( + + Tags + + + + + + )} + /> + ( + + + Description + + + + + + + + )} + /> +
+
+
+ +
+
+ + + ); +}; diff --git a/packages/epics/src/coherence/components/index.ts b/packages/epics/src/coherence/components/index.ts new file mode 100644 index 0000000000..edd3ce1e8f --- /dev/null +++ b/packages/epics/src/coherence/components/index.ts @@ -0,0 +1,14 @@ +export * from './chat-detail'; +export * from './chat-head'; +export * from './chat-message'; +export * from './chat-message.container'; +export * from './chat-message-input'; +export * from './coherence-block'; +export * from './coherence-type-button'; +export * from './coherence-priority-button'; +export * from './conversation-card'; +export * from './conversation-grid'; +export * from './conversation-section'; +export * from './create-signal-form'; +export * from './signal-grid'; +export * from './signal-section'; diff --git a/packages/epics/src/coherence/components/signal-card.tsx b/packages/epics/src/coherence/components/signal-card.tsx new file mode 100644 index 0000000000..2d21c135ba --- /dev/null +++ b/packages/epics/src/coherence/components/signal-card.tsx @@ -0,0 +1,212 @@ +'use client'; + +import { + Coherence, + COHERENCE_TYPE_OPTIONS, + useCoherenceMutationsWeb2Rsc, + useJwt, +} from '@hypha-platform/core/client'; +import { + BadgeItem, + BadgesList, + Button, + Card, + CardContent, + CardTitle, + ConfirmDialog, + LucideReactIcon, + Separator, + Skeleton, +} from '@hypha-platform/ui'; +import { + formatRelativeDateShort, + stripDescription, + stripMarkdown, +} from '@hypha-platform/ui-utils'; +import { + ChatBubbleIcon, + UpdateIcon, + ClockIcon, + DotFilledIcon, +} from '@radix-ui/react-icons'; +import React from 'react'; +import { CardButtonColorVariant } from '../../common'; +import { Users } from 'lucide-react'; + +type SignalCardProps = { isLoading: boolean; refresh: () => Promise }; + +export const SignalCard: React.FC = ({ + isLoading, + title, + description, + type, + priority, + slug, + createdAt, + tags, + archived, + messages = 0, + refresh, +}) => { + const { jwt: authToken } = useJwt(); + const { updateCoherenceBySlug } = useCoherenceMutationsWeb2Rsc(authToken); + + const coherenceType = React.useMemo( + () => COHERENCE_TYPE_OPTIONS.find((option) => option.type === type), + [type], + ); + + const badges: BadgeItem[] = [ + { + label: type, + icon: coherenceType?.icon as LucideReactIcon, + variant: 'outline', + colorVariant: (coherenceType?.colorVariant ?? + 'accent') as CardButtonColorVariant, + }, + ]; + + const tagList: BadgeItem[] = tags.map((tag) => ({ + label: `#${tag}`, + variant: 'solid', + colorVariant: 'neutral', + })); + + const handleUnarchive = React.useCallback(async () => { + console.log('Unarchive conversation'); + try { + await updateCoherenceBySlug({ slug, archived: false }); + await refresh(); + } catch (error) { + console.warn('Could not unarchive conversation:', error); + } + }, [slug, refresh]); + + return ( + + +
+
+ {badges?.length > 0 && ( + + )} +
+
+ + {formatRelativeDateShort(createdAt)} +
+
+
+ {priority === 'high' && ( +
+ + High Urgency +
+ )} + {priority === 'medium' && ( +
+ + Medium Urgency +
+ )} + {priority === 'low' && ( +
+ + Low Urgency +
+ )} +
+ + {title} + +
+ +
+ {stripDescription( + stripMarkdown(description, { + orderedListMarkers: false, + unorderedListMarkers: false, + }), + )} +
+
+
+
+ + +
{messages} mentions
+
+
+
+ {tagList?.length > 0 && ( + + )} +
+
+ +
+ {archived ? ( +
{ + e.stopPropagation(); + e.preventDefault(); + }} + > + + + +
+ ) : ( + + )} + +
+ +
+
+
+ ); +}; diff --git a/packages/epics/src/coherence/components/signal-grid.container.tsx b/packages/epics/src/coherence/components/signal-grid.container.tsx new file mode 100644 index 0000000000..5cbe6f179b --- /dev/null +++ b/packages/epics/src/coherence/components/signal-grid.container.tsx @@ -0,0 +1,41 @@ +import { Coherence, Order } from '@hypha-platform/core/client'; +import { SignalGrid } from './signal-grid'; + +type SignalGridContainerProps = { + basePath: string; + pagination: { + page: number; + firstPageSize: number; + pageSize: number; + searchTerm?: string; + order?: Order; + }; + signals: Coherence[]; + refresh: () => Promise; +}; + +export const SignalGridContainer = ({ + basePath, + pagination, + signals, + refresh, +}: SignalGridContainerProps) => { + const { page, firstPageSize, pageSize } = pagination; + const startIndex = page <= 1 ? 0 : firstPageSize + (page - 2) * pageSize; + const endIndex = Math.min( + signals.length, + page < 1 ? 0 : page === 1 ? firstPageSize : startIndex + pageSize, + ); + const paginatedSignals = signals.slice(startIndex, endIndex); + + return ( + ({ + ...signal, + }))} + refresh={refresh} + /> + ); +}; diff --git a/packages/epics/src/coherence/components/signal-grid.tsx b/packages/epics/src/coherence/components/signal-grid.tsx new file mode 100644 index 0000000000..b50ee53f4f --- /dev/null +++ b/packages/epics/src/coherence/components/signal-grid.tsx @@ -0,0 +1,41 @@ +import Link from 'next/link'; +import { SignalCard } from './signal-card'; +import { Coherence } from '@hypha-platform/core/client'; + +type SignalGridProps = { + isLoading: boolean; + basePath: string; + signals: Coherence[]; + refresh: () => Promise; +}; + +export function SignalGrid({ + isLoading, + basePath, + signals, + refresh, +}: SignalGridProps) { + return ( +
+ {signals.map((signal, index) => + signal.archived ? ( + + ) : ( + + + + ), + )} +
+ ); +} diff --git a/packages/epics/src/coherence/components/signal-section.tsx b/packages/epics/src/coherence/components/signal-section.tsx new file mode 100644 index 0000000000..b51a5c43d3 --- /dev/null +++ b/packages/epics/src/coherence/components/signal-section.tsx @@ -0,0 +1,232 @@ +'use client'; + +import { FC } from 'react'; +import { Text } from '@radix-ui/themes'; +import { useSignalsSection } from '../hooks'; +import { + Badge, + Button, + SectionFilter, + SectionLoadMore, + Separator, +} from '@hypha-platform/ui'; +import { Empty } from '../../common'; +import { SignalGridContainer } from './signal-grid.container'; +import { + Coherence, + COHERENCE_TYPE_OPTIONS, + CoherenceType, + DirectionType, +} from '@hypha-platform/core/client'; +import { PlusIcon, RocketIcon } from '@radix-ui/react-icons'; +import { + useParams, + usePathname, + useRouter, + useSearchParams, +} from 'next/navigation'; +import { Locale } from '@hypha-platform/i18n'; +import Link from 'next/link'; +import { cva } from 'class-variance-authority'; +import { cn } from '@hypha-platform/ui-utils'; +import React from 'react'; + +type SignalSectionProps = { + basePath: string; + signals: Coherence[]; + label?: string; + hasSearch?: boolean; + isLoading: boolean; + firstPageSize?: number; + pageSize?: number; + order?: string; + refresh: () => Promise; +}; + +export const SignalSection: FC = ({ + basePath, + signals, + label, + hasSearch = false, + isLoading, + firstPageSize = 3, + pageSize = 3, + refresh, +}) => { + const { lang, id } = useParams<{ lang: Locale; id: string }>(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + const { replace } = useRouter(); + const typeRaw = React.useMemo(() => { + return searchParams.get('type'); + }, [searchParams]); + const type = React.useMemo(() => { + return typeRaw ? (typeRaw as CoherenceType) : undefined; + }, [typeRaw]); + const chosenSignals = React.useMemo(() => { + if (!type) { + return signals; + } + const result = signals.filter((signal) => signal.type === type); + return result; + }, [signals, type]); + const { + pages, + loadMore, + pagination, + onUpdateSearch, + searchTerm, + filteredSignals, + } = useSignalsSection({ + signals: chosenSignals, + firstPageSize, + pageSize, + }); + + const onTagClick = React.useCallback( + (type: string) => { + const params = new URLSearchParams(searchParams); + if (type === 'all') { + params.delete('type'); + } else { + params.set('type', type); + } + replace(`${pathname}?${params.toString()}`); + }, + [searchParams, pathname], + ); + + const multiSelectVariants = cva( + 'm-1 transition ease-in-out delay-150 hover:-translate-y-1 hover:scale-110 duration-300', + { + variants: { + variant: { + default: + 'border-foreground/10 text-foreground text-neutral-500 bg-card hover:bg-card/80', + secondary: + 'border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80', + destructive: + 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', + inverted: 'inverted', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, + ); + + const createSignalHref = `/${lang}/dho/${id}/coherence/new-signal`; + + const typeOptions = React.useMemo(() => { + const typeMap = COHERENCE_TYPE_OPTIONS.reduce((acc, cur) => { + acc[cur.type] = 0; + return acc; + }, {} as { [key: string]: number }); + for (const signal of signals) { + const count = typeMap[signal.type] || 0; + typeMap[signal.type] = count + 1; + } + const coherenceTypes = COHERENCE_TYPE_OPTIONS.map((option) => ({ + label: option.title, + value: option.type, + count: typeMap[option.type], + })); + const typeOptions = [ + { label: 'All', value: 'all', count: signals.length }, + ...coherenceTypes, + ]; + return typeOptions; + }, [signals]); + + return ( +
+ +
+ + + + +
+
+
+ {typeOptions.map((typeOption) => ( + onTagClick(typeOption.value)} + > + {typeOption.label} {typeOption.count} + + ))} +
+ + + {pagination?.totalPages === 0 ? ( + +

List is empty

+
+ ) : ( +
+ {Array.from({ length: pages }).map((_, index) => ( + + ))} +
+ )} + {pagination?.totalPages === 0 ? null : ( + + + {pagination?.totalPages === pages ? 'No more' : 'Load more'} + + + )} +
+ ); +}; diff --git a/packages/epics/src/coherence/hooks/index.ts b/packages/epics/src/coherence/hooks/index.ts new file mode 100644 index 0000000000..0f4d639f76 --- /dev/null +++ b/packages/epics/src/coherence/hooks/index.ts @@ -0,0 +1,3 @@ +export * from './use-conversation'; +export * from './use-conversations-section'; +export * from './use-signals-section'; diff --git a/packages/epics/src/coherence/hooks/use-conversation.ts b/packages/epics/src/coherence/hooks/use-conversation.ts new file mode 100644 index 0000000000..96656d892d --- /dev/null +++ b/packages/epics/src/coherence/hooks/use-conversation.ts @@ -0,0 +1,35 @@ +'use client'; + +import React from 'react'; +import useSWR from 'swr'; +import { getCoherenceBySlug } from '../../../../core/src/coherence/server/web3'; + +type UseConversationProps = { + chatId: string; +}; + +export const useConversation = ({ chatId }: UseConversationProps) => { + const [isLoading, setIsLoading] = React.useState(false); + const [error, setError] = React.useState(null); + + const { data: conversation } = useSWR( + [chatId, 'loadConversation'], + async ([chatId]) => { + setIsLoading(true); + try { + return await getCoherenceBySlug({ slug: chatId }); + } catch (error) { + setError(error instanceof Error ? error.message : `${error}`); + } finally { + setIsLoading(false); + } + }, + { keepPreviousData: true, revalidateOnFocus: false }, + ); + + return { + conversation, + isLoading, + error, + }; +}; diff --git a/packages/epics/src/coherence/hooks/use-conversations-section.ts b/packages/epics/src/coherence/hooks/use-conversations-section.ts new file mode 100644 index 0000000000..e4aeb01d3e --- /dev/null +++ b/packages/epics/src/coherence/hooks/use-conversations-section.ts @@ -0,0 +1,82 @@ +import { useDebouncedCallback } from 'use-debounce'; +import React from 'react'; +import { Coherence } from '@hypha-platform/core/client'; + +export const useConversationsSection = ({ + conversations, + firstPageSize = 3, + pageSize = 3, +}: { + conversations: Coherence[]; + firstPageSize?: number; + pageSize?: number; +}) => { + if (firstPageSize <= 0 || pageSize <= 0) { + throw new Error('firstPageSize and pageSize must be positive numbers'); + } + const [activeFilter, setActiveFilter] = React.useState('most-recent'); + const [pages, setPages] = React.useState(1); + const [searchTerm, setSearchTerm] = React.useState( + undefined, + ); + + const onUpdateSearch = useDebouncedCallback((term: string) => { + setSearchTerm(term); + }, 300); + + const filteredConversations = React.useMemo(() => { + let result = conversations; + + const query = searchTerm?.trim()?.toLowerCase(); + if (query) { + result = result.filter((conv) => + [conv.title, conv.description].some( + (value) => value?.toLowerCase()?.includes(query) ?? false, + ), + ); + } + + return result; + }, [conversations, searchTerm]); + + const pagination = React.useMemo(() => { + const total = filteredConversations.length; + const totalPages = + total === 0 + ? 0 + : total <= firstPageSize + ? 1 + : 1 + Math.ceil((total - firstPageSize) / pageSize); + const hasNextPage = pages < totalPages; + + return { + page: pages, + pageSize, + total, + totalPages, + hasNextPage, + }; + }, [filteredConversations, pages, firstPageSize, pageSize]); + + React.useEffect(() => { + setPages(1); + }, [activeFilter, searchTerm]); + + const loadMore = React.useCallback(() => { + if (!pagination?.hasNextPage) return; + setPages((prev) => prev + 1); + }, [pagination?.hasNextPage]); + + return { + isLoading: false, + loadMore, + pagination, + pages, + setPages, + activeFilter, + setActiveFilter, + onUpdateSearch, + searchTerm, + filteredConversations, + }; +}; diff --git a/packages/epics/src/coherence/hooks/use-signals-section.ts b/packages/epics/src/coherence/hooks/use-signals-section.ts new file mode 100644 index 0000000000..f3e062f7d8 --- /dev/null +++ b/packages/epics/src/coherence/hooks/use-signals-section.ts @@ -0,0 +1,84 @@ +'use client'; + +import { Coherence } from '@hypha-platform/core/client'; +import React from 'react'; +import { useDebouncedCallback } from 'use-debounce'; + +export const useSignalsSection = ({ + signals, + firstPageSize = 3, + pageSize = 3, +}: { + signals: Coherence[]; + firstPageSize?: number; + pageSize?: number; +}) => { + if (firstPageSize <= 0 || pageSize <= 0) { + throw new Error('firstPageSize and pageSize must be positive numbers'); + } + const [activeFilter, setActiveFilter] = React.useState('most-recent'); + const [pages, setPages] = React.useState(1); + const [searchTerm, setSearchTerm] = React.useState( + undefined, + ); + + const onUpdateSearch = useDebouncedCallback((term: string) => { + setSearchTerm(term); + }, 300); + + const filteredSignals = React.useMemo(() => { + let result = signals; + + const query = searchTerm?.trim()?.toLowerCase(); + if (query) { + result = result.filter((sig) => + [sig.title, sig.description].some( + (value) => value?.toLowerCase()?.includes(query) ?? false, + ), + ); + } + + return result; + }, [signals, searchTerm]); + + const pagination = React.useMemo(() => { + const total = filteredSignals.length; + const totalPages = + total === 0 + ? 0 + : total <= firstPageSize + ? 1 + : 1 + Math.ceil((total - firstPageSize) / pageSize); + const hasNextPage = pages < totalPages; + + return { + page: pages, + pageSize, + total, + totalPages, + hasNextPage, + }; + }, [filteredSignals, pages, firstPageSize, pageSize]); + + React.useEffect(() => { + setPages(1); + }, [activeFilter, searchTerm]); + + const loadMore = React.useCallback(() => { + if (!pagination?.hasNextPage) return; + setPages((prev) => prev + 1); + }, [pagination?.hasNextPage]); + + return { + isLoading: false, + loadMore, + pagination, + pages, + setPages, + activeFilter, + setActiveFilter, + onUpdateSearch, + searchTerm, + filteredSignals, + }; +}; diff --git a/packages/epics/src/coherence/index.ts b/packages/epics/src/coherence/index.ts new file mode 100644 index 0000000000..289edd7153 --- /dev/null +++ b/packages/epics/src/coherence/index.ts @@ -0,0 +1,4 @@ +export * from './components'; +export * from './hooks'; + +export * from './types'; diff --git a/packages/epics/src/coherence/types.ts b/packages/epics/src/coherence/types.ts new file mode 100644 index 0000000000..2db094c962 --- /dev/null +++ b/packages/epics/src/coherence/types.ts @@ -0,0 +1,22 @@ +import { Locale } from '@hypha-platform/i18n'; + +export type ChatCreatorType = { + avatar?: string; + name?: string; + surname?: string; + type?: 'person' | 'space'; + address?: string; +}; + +export const COHERENCE_ORDERS = [ + 'mostviews', + 'mostmessages', + 'mostrecent', +] as const; +export type CoherenceOrder = (typeof COHERENCE_ORDERS)[number]; + +export type ChatPageParams = { + id: string; + lang: Locale; + chatId: string; +}; diff --git a/packages/epics/src/common/ai-left-panel-layout.tsx b/packages/epics/src/common/ai-left-panel-layout.tsx new file mode 100644 index 0000000000..740609a46c --- /dev/null +++ b/packages/epics/src/common/ai-left-panel-layout.tsx @@ -0,0 +1,128 @@ +'use client'; + +import { useCallback, useRef, useState } from 'react'; +import { Bot, ChevronsLeftRight, PanelLeftOpen } from 'lucide-react'; + +import { AiLeftPanel } from './ai-left-panel'; +import { useIsMobile } from '../hooks'; +import { Drawer, DrawerContent } from '@hypha-platform/ui'; +import { cn } from '@hypha-platform/ui-utils'; + +const MIN_WIDTH = 240; +const MAX_WIDTH = 600; +const DEFAULT_WIDTH = 320; + +type AiLeftPanelLayoutProps = { + children: React.ReactNode; +}; + +export function AiLeftPanelLayout({ children }: AiLeftPanelLayoutProps) { + const isMobile = useIsMobile(); + const [panelOpen, setPanelOpen] = useState(true); + const [panelWidth, setPanelWidth] = useState(DEFAULT_WIDTH); + const [isDragging, setIsDragging] = useState(false); + const dragStartX = useRef(0); + const dragStartWidth = useRef(DEFAULT_WIDTH); + + const onResizeMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + dragStartX.current = e.clientX; + dragStartWidth.current = panelWidth; + setIsDragging(true); + + const onMouseMove = (e: MouseEvent) => { + const delta = e.clientX - dragStartX.current; + const newWidth = Math.min( + MAX_WIDTH, + Math.max(MIN_WIDTH, dragStartWidth.current + delta), + ); + setPanelWidth(newWidth); + }; + + const onMouseUp = () => { + setIsDragging(false); + window.removeEventListener('mousemove', onMouseMove); + window.removeEventListener('mouseup', onMouseUp); + }; + + window.addEventListener('mousemove', onMouseMove); + window.addEventListener('mouseup', onMouseUp); + }, + [panelWidth], + ); + + return ( +
+ {!isMobile && ( + <> +
+ setPanelOpen(false)} /> + + {panelOpen && ( +
+
+ +
+
+ )} +
+ + )} + + {!panelOpen && ( + + )} + + {isMobile && ( + + +
+ setPanelOpen(false)} /> +
+
+
+ )} + +
+ {children} +
+
+ ); +} diff --git a/packages/epics/src/common/ai-left-panel.tsx b/packages/epics/src/common/ai-left-panel.tsx new file mode 100644 index 0000000000..fb676b0ba7 --- /dev/null +++ b/packages/epics/src/common/ai-left-panel.tsx @@ -0,0 +1,171 @@ +'use client'; + +import { useState } from 'react'; +import { useChat } from '@ai-sdk/react'; +import { DefaultChatTransport } from 'ai'; +import { LogIn } from 'lucide-react'; + +import { useAuthentication } from '@hypha-platform/authentication'; +import { cn } from '@hypha-platform/ui-utils'; + +import { + AiPanelHeader, + AiPanelMessages, + AiPanelChatBar, + MODEL_OPTIONS, + MOCK_SUGGESTIONS, +} from './ai-panel'; +import { convertFilesToParts } from './ai-panel/convert-files-to-parts'; + +type AiLeftPanelProps = { + onClose: () => void; + className?: string; +}; + +export function AiLeftPanel({ onClose, className }: AiLeftPanelProps) { + const { + isAuthenticated, + login, + isLoading: isAuthLoading, + getAccessToken, + } = useAuthentication(); + const [input, setInput] = useState(''); + const [attachments, setAttachments] = useState([]); + const [selectedModel, setSelectedModel] = useState(MODEL_OPTIONS[0]!); + const [showSuggestions, setShowSuggestions] = useState(true); + + const { messages, sendMessage, status, stop, setMessages } = useChat({ + transport: new DefaultChatTransport({ + api: '/api/chat', + }), + }); + + const isStreaming = status === 'streaming' || status === 'submitted'; + + const handleSend = async () => { + const text = input.trim(); + if ((!text && attachments.length === 0) || isStreaming) return; + const token = await getAccessToken(); + + const textPart = { type: 'text' as const, text: text || '(no text)' }; + const fileParts = + attachments.length > 0 ? await convertFilesToParts(attachments) : []; + + sendMessage( + { + role: 'user', + parts: [textPart, ...fileParts], + }, + { + body: { modelId: selectedModel.id }, + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }, + ); + setInput(''); + setAttachments([]); + setShowSuggestions(false); + }; + + const handleSuggestionSelect = (text: string) => { + setShowSuggestions(false); + setInput(text); + }; + + const handleResetChat = () => { + setMessages([]); + setShowSuggestions(true); + }; + + const handleStop = () => { + stop(); + }; + + if (isAuthLoading) { + return ( +
+ +
+
+
+
+ ); + } + + if (!isAuthenticated) { + return ( +
+ +
+

+ Sign in to use Hypha AI +

+ +
+
+ ); + } + + return ( +
+ + + + + +
+ ); +} diff --git a/packages/epics/src/common/ai-panel/ai-panel-chat-bar.tsx b/packages/epics/src/common/ai-panel/ai-panel-chat-bar.tsx new file mode 100644 index 0000000000..1b7003d802 --- /dev/null +++ b/packages/epics/src/common/ai-panel/ai-panel-chat-bar.tsx @@ -0,0 +1,351 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { + Code2, + Image, + Mic, + Paperclip, + Search, + Send, + Square, + X, +} from 'lucide-react'; + +import { cn } from '@hypha-platform/ui-utils'; + +const ACCEPT_FILE = 'image/*,application/pdf,text/*'; +const ACCEPT_IMAGE = 'image/*'; + +interface SpeechRecognitionInstance { + continuous: boolean; + interimResults: boolean; + lang: string; + onresult: ((e: SpeechRecognitionResultEvent) => void) | null; + onend: (() => void) | null; + start: () => void; + stop: () => void; +} + +interface SpeechRecognitionResultEvent { + results: SpeechRecognitionResultList; +} + +interface SpeechRecognitionResultList { + [index: number]: SpeechRecognitionResult; + length: number; +} + +interface SpeechRecognitionResult { + 0?: { transcript?: string }; + length: number; +} + +function AttachmentPreview({ + file, + onRemove, +}: { + file: File; + onRemove: () => void; +}) { + const [url, setUrl] = useState(null); + + useEffect(() => { + if (!file.type.startsWith('image/')) return; + const u = URL.createObjectURL(file); + setUrl(u); + return () => URL.revokeObjectURL(u); + }, [file]); + + return ( +
+ {url ? ( + {file.name} + ) : ( + + {file.name} + + )} + +
+ ); +} + +type AiPanelChatBarProps = { + value: string; + onChange: (value: string) => void; + onSend: () => void; + onStop?: () => void; + attachments?: File[]; + onAttachmentsChange?: (files: File[]) => void; + isStreaming?: boolean; + placeholder?: string; +}; + +export function AiPanelChatBar({ + value, + onChange, + onSend, + onStop, + attachments = [], + onAttachmentsChange, + isStreaming = false, + placeholder = 'Ask Hypha AI anything...', +}: AiPanelChatBarProps) { + const textareaRef = useRef(null); + const fileInputRef = useRef(null); + const imageInputRef = useRef(null); + const recognitionRef = useRef(null); + const [isListening, setIsListening] = useState(false); + + useEffect(() => { + return () => { + recognitionRef.current?.stop(); + recognitionRef.current = null; + }; + }, []); + + const autoResize = useCallback(() => { + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = + Math.min(textareaRef.current.scrollHeight, 160) + 'px'; + } + }, []); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + onSend(); + } + }; + + const handleFileChange = (e: React.ChangeEvent) => { + const files = e.target.files; + if (files && onAttachmentsChange) { + onAttachmentsChange([...attachments, ...Array.from(files)]); + } + e.target.value = ''; + }; + + const removeAttachment = (index: number) => { + if (onAttachmentsChange) { + onAttachmentsChange(attachments.filter((_, i) => i !== index)); + } + }; + + const insertCodeBlock = () => { + const ta = textareaRef.current; + if (!ta) return; + const start = ta.selectionStart; + const end = ta.selectionEnd; + const selected = value.slice(start, end); + const wrapper = selected ? `\`\`\`\n${selected}\n\`\`\`` : '\n```\n\n```\n'; + const newValue = value.slice(0, start) + wrapper + value.slice(end); + onChange(newValue); + ta.focus(); + }; + + const toggleVoiceInput = useCallback(() => { + if ( + !('webkitSpeechRecognition' in window) && + !('SpeechRecognition' in window) + ) { + return; + } + type SpeechRecognitionCtor = new () => SpeechRecognitionInstance; + const SpeechRecognitionCtor = + (window as unknown as { SpeechRecognition?: SpeechRecognitionCtor }) + .SpeechRecognition ?? + (window as unknown as { webkitSpeechRecognition?: SpeechRecognitionCtor }) + .webkitSpeechRecognition; + if (!SpeechRecognitionCtor) return; + + if (isListening) { + recognitionRef.current?.stop(); + recognitionRef.current = null; + setIsListening(false); + return; + } + + const recognition = new SpeechRecognitionCtor(); + recognitionRef.current = recognition; + recognition.continuous = true; + recognition.interimResults = true; + recognition.lang = navigator.language || 'en-US'; + + recognition.onresult = (e: SpeechRecognitionResultEvent) => { + const results = Array.from(e.results) as SpeechRecognitionResult[]; + const transcript = results.map((r) => r[0]?.transcript ?? '').join(''); + if (transcript) { + onChange(value + (value ? ' ' : '') + transcript); + autoResize(); + } + }; + + recognition.onend = () => { + recognitionRef.current = null; + setIsListening(false); + }; + recognition.start(); + setIsListening(true); + }, [isListening, value, onChange, autoResize]); + + const canSend = + (value.trim().length > 0 || attachments.length > 0) && !isStreaming; + + return ( +
+ + +
+ {/* Toolbar */} +
+ + + + +
+ +
+ + {/* Attachments preview */} + {attachments.length > 0 && ( +
+ {attachments.map((file, i) => ( + removeAttachment(i)} + /> + ))} +
+ )} + + {/* Textarea — full width */} +