diff --git a/bun.lock b/bun.lock index c6f4a92..58de404 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,7 @@ "": { "name": "sipher", "dependencies": { + "@better-fetch/fetch": "^1.1.21", "@convex-dev/better-auth": "^0.10.4", "@marsidev/react-turnstile": "^1.4.0", "@matrix-org/olm": "^3.2.15", diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index b17e83b..b10c6a3 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -62,22 +62,77 @@ export declare const components: { displayUsername?: null | string; email: string; emailVerified: boolean; - friends?: Array; image?: null | string; metadata?: { phrasePreference: "comforting" | "mocking" | "both"; }; name: string; - status?: { - isUserSet: boolean; - status: "online" | "busy" | "offline" | "away"; - }; updatedAt: number; userId?: null | string; username?: null | string; }; model: "user"; } + | { + data: { + isUserSet: boolean; + status: "online" | "busy" | "offline" | "away"; + updatedAt: number; + userId: string; + }; + model: "userStatus"; + } + | { + data: { + acceptedAt?: number; + createdAt: number; + declinedAt?: number; + expiresAt?: number; + ignoredAt?: number; + method: "receive" | "send"; + requestId: string; + requestTo: string; + userId: string; + }; + model: "friendRequests"; + } + | { + data: { createdAt: number; friendId: string; userId: string }; + model: "friends"; + } + | { + data: { + attachments?: Array; + authorId: string; + channelId: string; + content: string; + createdAt: string; + createdTimestamp: number; + editedAt?: string; + guildId?: string; + id: string; + inGuild?: boolean; + nonce?: string; + position?: number; + referencedMessage?: null | string | string | string; + url?: string; + }; + model: "messages"; + } + | { + data: { + contentType: string; + description: null | string; + ephemeral: boolean; + height?: number; + id: string; + size: number; + spoiler: boolean; + url: string; + width?: number; + }; + model: "attachments"; + } | { data: { createdAt: number; @@ -158,8 +213,176 @@ export declare const components: { | "username" | "displayUsername" | "metadata" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "userStatus"; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "userId" | "status" - | "friends" + | "isUserSet" + | "updatedAt" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "friendRequests"; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "userId" + | "requestTo" + | "method" + | "requestId" + | "createdAt" + | "expiresAt" + | "acceptedAt" + | "declinedAt" + | "ignoredAt" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "friends"; + where?: Array<{ + connector?: "AND" | "OR"; + field: "userId" | "friendId" | "createdAt" | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "messages"; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "inGuild" + | "attachments" + | "authorId" + | "channelId" + | "content" + | "createdAt" + | "createdTimestamp" + | "editedAt" + | "guildId" + | "id" + | "nonce" + | "position" + | "referencedMessage" + | "url" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "attachments"; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "contentType" + | "description" + | "ephemeral" + | "height" + | "width" + | "id" + | "size" + | "spoiler" + | "url" | "_id"; operator?: | "lt" @@ -371,8 +594,176 @@ export declare const components: { | "username" | "displayUsername" | "metadata" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "userStatus"; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "userId" | "status" - | "friends" + | "isUserSet" + | "updatedAt" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "friendRequests"; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "userId" + | "requestTo" + | "method" + | "requestId" + | "createdAt" + | "expiresAt" + | "acceptedAt" + | "declinedAt" + | "ignoredAt" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "friends"; + where?: Array<{ + connector?: "AND" | "OR"; + field: "userId" | "friendId" | "createdAt" | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "messages"; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "inGuild" + | "attachments" + | "authorId" + | "channelId" + | "content" + | "createdAt" + | "createdTimestamp" + | "editedAt" + | "guildId" + | "id" + | "nonce" + | "position" + | "referencedMessage" + | "url" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "attachments"; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "contentType" + | "description" + | "ephemeral" + | "height" + | "width" + | "id" + | "size" + | "spoiler" + | "url" | "_id"; operator?: | "lt" @@ -563,6 +954,11 @@ export declare const components: { limit?: number; model: | "user" + | "userStatus" + | "friendRequests" + | "friends" + | "messages" + | "attachments" | "session" | "account" | "verification" @@ -610,6 +1006,11 @@ export declare const components: { { model: | "user" + | "userStatus" + | "friendRequests" + | "friends" + | "messages" + | "attachments" | "session" | "account" | "verification" @@ -654,16 +1055,11 @@ export declare const components: { displayUsername?: null | string; email?: string; emailVerified?: boolean; - friends?: Array; image?: null | string; metadata?: { phrasePreference: "comforting" | "mocking" | "both"; }; name?: string; - status?: { - isUserSet: boolean; - status: "online" | "busy" | "offline" | "away"; - }; updatedAt?: number; userId?: null | string; username?: null | string; @@ -681,8 +1077,225 @@ export declare const components: { | "username" | "displayUsername" | "metadata" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "userStatus"; + update: { + isUserSet?: boolean; + status?: "online" | "busy" | "offline" | "away"; + updatedAt?: number; + userId?: string; + }; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "userId" | "status" - | "friends" + | "isUserSet" + | "updatedAt" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "friendRequests"; + update: { + acceptedAt?: number; + createdAt?: number; + declinedAt?: number; + expiresAt?: number; + ignoredAt?: number; + method?: "receive" | "send"; + requestId?: string; + requestTo?: string; + userId?: string; + }; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "userId" + | "requestTo" + | "method" + | "requestId" + | "createdAt" + | "expiresAt" + | "acceptedAt" + | "declinedAt" + | "ignoredAt" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "friends"; + update: { + createdAt?: number; + friendId?: string; + userId?: string; + }; + where?: Array<{ + connector?: "AND" | "OR"; + field: "userId" | "friendId" | "createdAt" | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "messages"; + update: { + attachments?: Array; + authorId?: string; + channelId?: string; + content?: string; + createdAt?: string; + createdTimestamp?: number; + editedAt?: string; + guildId?: string; + id?: string; + inGuild?: boolean; + nonce?: string; + position?: number; + referencedMessage?: null | string | string | string; + url?: string; + }; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "inGuild" + | "attachments" + | "authorId" + | "channelId" + | "content" + | "createdAt" + | "createdTimestamp" + | "editedAt" + | "guildId" + | "id" + | "nonce" + | "position" + | "referencedMessage" + | "url" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "attachments"; + update: { + contentType?: string; + description?: null | string; + ephemeral?: boolean; + height?: number; + id?: string; + size?: number; + spoiler?: boolean; + url?: string; + width?: number; + }; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "contentType" + | "description" + | "ephemeral" + | "height" + | "width" + | "id" + | "size" + | "spoiler" + | "url" | "_id"; operator?: | "lt" @@ -926,16 +1539,11 @@ export declare const components: { displayUsername?: null | string; email?: string; emailVerified?: boolean; - friends?: Array; image?: null | string; metadata?: { phrasePreference: "comforting" | "mocking" | "both"; }; name?: string; - status?: { - isUserSet: boolean; - status: "online" | "busy" | "offline" | "away"; - }; updatedAt?: number; userId?: null | string; username?: null | string; @@ -953,8 +1561,225 @@ export declare const components: { | "username" | "displayUsername" | "metadata" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "userStatus"; + update: { + isUserSet?: boolean; + status?: "online" | "busy" | "offline" | "away"; + updatedAt?: number; + userId?: string; + }; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "userId" | "status" - | "friends" + | "isUserSet" + | "updatedAt" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "friendRequests"; + update: { + acceptedAt?: number; + createdAt?: number; + declinedAt?: number; + expiresAt?: number; + ignoredAt?: number; + method?: "receive" | "send"; + requestId?: string; + requestTo?: string; + userId?: string; + }; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "userId" + | "requestTo" + | "method" + | "requestId" + | "createdAt" + | "expiresAt" + | "acceptedAt" + | "declinedAt" + | "ignoredAt" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "friends"; + update: { + createdAt?: number; + friendId?: string; + userId?: string; + }; + where?: Array<{ + connector?: "AND" | "OR"; + field: "userId" | "friendId" | "createdAt" | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "messages"; + update: { + attachments?: Array; + authorId?: string; + channelId?: string; + content?: string; + createdAt?: string; + createdTimestamp?: number; + editedAt?: string; + guildId?: string; + id?: string; + inGuild?: boolean; + nonce?: string; + position?: number; + referencedMessage?: null | string | string | string; + url?: string; + }; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "inGuild" + | "attachments" + | "authorId" + | "channelId" + | "content" + | "createdAt" + | "createdTimestamp" + | "editedAt" + | "guildId" + | "id" + | "nonce" + | "position" + | "referencedMessage" + | "url" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "attachments"; + update: { + contentType?: string; + description?: null | string; + ephemeral?: boolean; + height?: number; + id?: string; + size?: number; + spoiler?: boolean; + url?: string; + width?: number; + }; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "contentType" + | "description" + | "ephemeral" + | "height" + | "width" + | "id" + | "size" + | "spoiler" + | "url" | "_id"; operator?: | "lt" @@ -1202,10 +2027,34 @@ export declare const components: { }; user: { index: { + answerFriendRequest: FunctionReference< + "mutation", + "internal", + { answer: "accept" | "decline" | "ignore"; requestId: string }, + any + >; + getFriendRequests: FunctionReference<"query", "internal", any, any>; + getFriends: FunctionReference<"query", "internal", any, any>; + getUserStatus: FunctionReference<"query", "internal", any, any>; + sendFriendRequest: FunctionReference< + "mutation", + "internal", + { username: string }, + any + >; + updateUserMetadata: FunctionReference< + "mutation", + "internal", + { metadata: { phrasePreference: "comforting" | "mocking" | "both" } }, + any + >; updateUserStatus: FunctionReference< "mutation", "internal", - { isUserSet: boolean; status: string }, + { + isUserSet: boolean; + status: "online" | "busy" | "offline" | "away"; + }, any >; }; diff --git a/convex/auth.ts b/convex/auth.ts index 0aa79ec..9054754 100644 --- a/convex/auth.ts +++ b/convex/auth.ts @@ -3,7 +3,6 @@ import { convex } from "@convex-dev/better-auth/plugins"; import { betterAuth, type BetterAuthOptions } from "better-auth"; import { captcha, oneTimeToken, openAPI, username } from "better-auth/plugins"; import { v } from "convex/values"; -import { z } from "zod"; import { components } from "./_generated/api"; import { DataModel } from "./_generated/dataModel"; import { mutation, query } from "./_generated/server"; @@ -23,15 +22,6 @@ export const authComponent = createClient( } ); -const metadataSchema = z.object({ - phrasePreference: z.enum(["comforting", "mocking", "both"]), -}) - -const statusSchema = z.object({ - status: z.enum(["online", "busy", "offline", "away"]), - isUserSet: z.boolean(), -}); - export const createAuthOptions = (ctx: GenericCtx) => { return { baseURL: siteUrl, @@ -45,45 +35,12 @@ export const createAuthOptions = (ctx: GenericCtx) => { additionalFields: { metadata: { type: "json", - defaultValue: () => { - const metadata = metadataSchema.parse({ - phrasePreference: "comforting", - }) - - return metadata.phrasePreference; - }, required: false, }, friends: { type: "string[]", - defaultValue: [], required: false, index: true - }, - status: { - type: "json", - defaultValue: () => { - return { - status: "offline", - isUserSet: false, - } - }, - required: false, - index: true, - transform: { - input: (status) => { - return statusSchema.safeParse(status).success ? status : { - status: "offline", - isUserSet: false, - }; - }, - output: (status) => { - return statusSchema.safeParse(status).success ? status : { - status: "offline", - isUserSet: false, - }; - } - } } }, }, @@ -103,7 +60,7 @@ export const createAuthOptions = (ctx: GenericCtx) => { } }), oneTimeToken(), - openAPI() + openAPI(), ], } satisfies BetterAuthOptions; } @@ -159,7 +116,7 @@ export const retrieveServerOlmAccount = query({ export const updateUserStatus = mutation({ args: { - status: v.string(), + status: v.union(v.literal("online"), v.literal("busy"), v.literal("offline"), v.literal("away")), isUserSet: v.boolean(), }, handler: async (ctx, args) => { @@ -168,4 +125,62 @@ export const updateUserStatus = mutation({ isUserSet: args.isUserSet, }); }, +}); + +export const updateUserMetadata = mutation({ + args: { + metadata: v.object({ + phrasePreference: v.union(v.literal("comforting"), v.literal("mocking"), v.literal("both")), + }), + }, + handler: async (ctx, args) => { + return ctx.runMutation(components.betterAuth.user.index.updateUserMetadata, { + metadata: args.metadata, + }); + }, +}); + +export const sendFriendRequest = mutation({ + args: { + username: v.string(), + }, + handler: async (ctx, args) => { + return ctx.runMutation(components.betterAuth.user.index.sendFriendRequest, { + username: args.username, + }); + }, +}); + +export const answerFriendRequest = mutation({ + args: { + requestId: v.string(), + answer: v.union(v.literal("accept"), v.literal("decline"), v.literal("ignore")), + }, + handler: async (ctx, args) => { + return ctx.runMutation(components.betterAuth.user.index.answerFriendRequest, { + requestId: args.requestId, + answer: args.answer, + }); + }, +}); + +export const getFriendRequests = query({ + args: {}, + handler: async (ctx) => { + return ctx.runQuery(components.betterAuth.user.index.getFriendRequests) + }, +}); + +export const getFriends = query({ + args: {}, + handler: async (ctx) => { + return ctx.runQuery(components.betterAuth.user.index.getFriends) + }, +}); + +export const getUserStatus = query({ + args: {}, + handler: async (ctx) => { + return ctx.runQuery(components.betterAuth.user.index.getUserStatus) + }, }); \ No newline at end of file diff --git a/convex/betterAuth/_generated/api.ts b/convex/betterAuth/_generated/api.ts index fd792d3..036a9b7 100644 --- a/convex/betterAuth/_generated/api.ts +++ b/convex/betterAuth/_generated/api.ts @@ -11,6 +11,7 @@ import type * as adapter from "../adapter.js"; import type * as auth from "../auth.js"; import type * as olm_index from "../olm/index.js"; +import type * as schemas_user from "../schemas/user.js"; import type * as user_index from "../user/index.js"; import type { @@ -24,6 +25,7 @@ const fullApi: ApiFromModules<{ adapter: typeof adapter; auth: typeof auth; "olm/index": typeof olm_index; + "schemas/user": typeof schemas_user; "user/index": typeof user_index; }> = anyApi as any; diff --git a/convex/betterAuth/_generated/component.ts b/convex/betterAuth/_generated/component.ts index 8ba446b..ebb09c3 100644 --- a/convex/betterAuth/_generated/component.ts +++ b/convex/betterAuth/_generated/component.ts @@ -35,22 +35,77 @@ export type ComponentApi = displayUsername?: null | string; email: string; emailVerified: boolean; - friends?: Array; image?: null | string; metadata?: { phrasePreference: "comforting" | "mocking" | "both"; }; name: string; - status?: { - isUserSet: boolean; - status: "online" | "busy" | "offline" | "away"; - }; updatedAt: number; userId?: null | string; username?: null | string; }; model: "user"; } + | { + data: { + isUserSet: boolean; + status: "online" | "busy" | "offline" | "away"; + updatedAt: number; + userId: string; + }; + model: "userStatus"; + } + | { + data: { + acceptedAt?: number; + createdAt: number; + declinedAt?: number; + expiresAt?: number; + ignoredAt?: number; + method: "receive" | "send"; + requestId: string; + requestTo: string; + userId: string; + }; + model: "friendRequests"; + } + | { + data: { createdAt: number; friendId: string; userId: string }; + model: "friends"; + } + | { + data: { + attachments?: Array; + authorId: string; + channelId: string; + content: string; + createdAt: string; + createdTimestamp: number; + editedAt?: string; + guildId?: string; + id: string; + inGuild?: boolean; + nonce?: string; + position?: number; + referencedMessage?: null | string | string | string; + url?: string; + }; + model: "messages"; + } + | { + data: { + contentType: string; + description: null | string; + ephemeral: boolean; + height?: number; + id: string; + size: number; + spoiler: boolean; + url: string; + width?: number; + }; + model: "attachments"; + } | { data: { createdAt: number; @@ -132,8 +187,176 @@ export type ComponentApi = | "username" | "displayUsername" | "metadata" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "userStatus"; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "userId" | "status" - | "friends" + | "isUserSet" + | "updatedAt" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "friendRequests"; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "userId" + | "requestTo" + | "method" + | "requestId" + | "createdAt" + | "expiresAt" + | "acceptedAt" + | "declinedAt" + | "ignoredAt" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "friends"; + where?: Array<{ + connector?: "AND" | "OR"; + field: "userId" | "friendId" | "createdAt" | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "messages"; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "inGuild" + | "attachments" + | "authorId" + | "channelId" + | "content" + | "createdAt" + | "createdTimestamp" + | "editedAt" + | "guildId" + | "id" + | "nonce" + | "position" + | "referencedMessage" + | "url" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "attachments"; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "contentType" + | "description" + | "ephemeral" + | "height" + | "width" + | "id" + | "size" + | "spoiler" + | "url" | "_id"; operator?: | "lt" @@ -346,8 +569,176 @@ export type ComponentApi = | "username" | "displayUsername" | "metadata" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "userStatus"; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "userId" | "status" - | "friends" + | "isUserSet" + | "updatedAt" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "friendRequests"; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "userId" + | "requestTo" + | "method" + | "requestId" + | "createdAt" + | "expiresAt" + | "acceptedAt" + | "declinedAt" + | "ignoredAt" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "friends"; + where?: Array<{ + connector?: "AND" | "OR"; + field: "userId" | "friendId" | "createdAt" | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "messages"; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "inGuild" + | "attachments" + | "authorId" + | "channelId" + | "content" + | "createdAt" + | "createdTimestamp" + | "editedAt" + | "guildId" + | "id" + | "nonce" + | "position" + | "referencedMessage" + | "url" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "attachments"; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "contentType" + | "description" + | "ephemeral" + | "height" + | "width" + | "id" + | "size" + | "spoiler" + | "url" | "_id"; operator?: | "lt" @@ -539,6 +930,11 @@ export type ComponentApi = limit?: number; model: | "user" + | "userStatus" + | "friendRequests" + | "friends" + | "messages" + | "attachments" | "session" | "account" | "verification" @@ -587,6 +983,11 @@ export type ComponentApi = { model: | "user" + | "userStatus" + | "friendRequests" + | "friends" + | "messages" + | "attachments" | "session" | "account" | "verification" @@ -632,16 +1033,11 @@ export type ComponentApi = displayUsername?: null | string; email?: string; emailVerified?: boolean; - friends?: Array; image?: null | string; metadata?: { phrasePreference: "comforting" | "mocking" | "both"; }; name?: string; - status?: { - isUserSet: boolean; - status: "online" | "busy" | "offline" | "away"; - }; updatedAt?: number; userId?: null | string; username?: null | string; @@ -659,8 +1055,225 @@ export type ComponentApi = | "username" | "displayUsername" | "metadata" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "userStatus"; + update: { + isUserSet?: boolean; + status?: "online" | "busy" | "offline" | "away"; + updatedAt?: number; + userId?: string; + }; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "userId" | "status" - | "friends" + | "isUserSet" + | "updatedAt" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "friendRequests"; + update: { + acceptedAt?: number; + createdAt?: number; + declinedAt?: number; + expiresAt?: number; + ignoredAt?: number; + method?: "receive" | "send"; + requestId?: string; + requestTo?: string; + userId?: string; + }; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "userId" + | "requestTo" + | "method" + | "requestId" + | "createdAt" + | "expiresAt" + | "acceptedAt" + | "declinedAt" + | "ignoredAt" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "friends"; + update: { + createdAt?: number; + friendId?: string; + userId?: string; + }; + where?: Array<{ + connector?: "AND" | "OR"; + field: "userId" | "friendId" | "createdAt" | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "messages"; + update: { + attachments?: Array; + authorId?: string; + channelId?: string; + content?: string; + createdAt?: string; + createdTimestamp?: number; + editedAt?: string; + guildId?: string; + id?: string; + inGuild?: boolean; + nonce?: string; + position?: number; + referencedMessage?: null | string | string | string; + url?: string; + }; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "inGuild" + | "attachments" + | "authorId" + | "channelId" + | "content" + | "createdAt" + | "createdTimestamp" + | "editedAt" + | "guildId" + | "id" + | "nonce" + | "position" + | "referencedMessage" + | "url" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "attachments"; + update: { + contentType?: string; + description?: null | string; + ephemeral?: boolean; + height?: number; + id?: string; + size?: number; + spoiler?: boolean; + url?: string; + width?: number; + }; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "contentType" + | "description" + | "ephemeral" + | "height" + | "width" + | "id" + | "size" + | "spoiler" + | "url" | "_id"; operator?: | "lt" @@ -905,16 +1518,11 @@ export type ComponentApi = displayUsername?: null | string; email?: string; emailVerified?: boolean; - friends?: Array; image?: null | string; metadata?: { phrasePreference: "comforting" | "mocking" | "both"; }; name?: string; - status?: { - isUserSet: boolean; - status: "online" | "busy" | "offline" | "away"; - }; updatedAt?: number; userId?: null | string; username?: null | string; @@ -932,8 +1540,225 @@ export type ComponentApi = | "username" | "displayUsername" | "metadata" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "userStatus"; + update: { + isUserSet?: boolean; + status?: "online" | "busy" | "offline" | "away"; + updatedAt?: number; + userId?: string; + }; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "userId" | "status" - | "friends" + | "isUserSet" + | "updatedAt" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "friendRequests"; + update: { + acceptedAt?: number; + createdAt?: number; + declinedAt?: number; + expiresAt?: number; + ignoredAt?: number; + method?: "receive" | "send"; + requestId?: string; + requestTo?: string; + userId?: string; + }; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "userId" + | "requestTo" + | "method" + | "requestId" + | "createdAt" + | "expiresAt" + | "acceptedAt" + | "declinedAt" + | "ignoredAt" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "friends"; + update: { + createdAt?: number; + friendId?: string; + userId?: string; + }; + where?: Array<{ + connector?: "AND" | "OR"; + field: "userId" | "friendId" | "createdAt" | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "messages"; + update: { + attachments?: Array; + authorId?: string; + channelId?: string; + content?: string; + createdAt?: string; + createdTimestamp?: number; + editedAt?: string; + guildId?: string; + id?: string; + inGuild?: boolean; + nonce?: string; + position?: number; + referencedMessage?: null | string | string | string; + url?: string; + }; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "inGuild" + | "attachments" + | "authorId" + | "channelId" + | "content" + | "createdAt" + | "createdTimestamp" + | "editedAt" + | "guildId" + | "id" + | "nonce" + | "position" + | "referencedMessage" + | "url" + | "_id"; + operator?: + | "lt" + | "lte" + | "gt" + | "gte" + | "eq" + | "in" + | "not_in" + | "ne" + | "contains" + | "starts_with" + | "ends_with"; + value: + | string + | number + | boolean + | Array + | Array + | null; + }>; + } + | { + model: "attachments"; + update: { + contentType?: string; + description?: null | string; + ephemeral?: boolean; + height?: number; + id?: string; + size?: number; + spoiler?: boolean; + url?: string; + width?: number; + }; + where?: Array<{ + connector?: "AND" | "OR"; + field: + | "contentType" + | "description" + | "ephemeral" + | "height" + | "width" + | "id" + | "size" + | "spoiler" + | "url" | "_id"; operator?: | "lt" @@ -1184,10 +2009,43 @@ export type ComponentApi = }; user: { index: { + answerFriendRequest: FunctionReference< + "mutation", + "internal", + { answer: "accept" | "decline" | "ignore"; requestId: string }, + any, + Name + >; + getFriendRequests: FunctionReference< + "query", + "internal", + any, + any, + Name + >; + getFriends: FunctionReference<"query", "internal", any, any, Name>; + getUserStatus: FunctionReference<"query", "internal", any, any, Name>; + sendFriendRequest: FunctionReference< + "mutation", + "internal", + { username: string }, + any, + Name + >; + updateUserMetadata: FunctionReference< + "mutation", + "internal", + { metadata: { phrasePreference: "comforting" | "mocking" | "both" } }, + any, + Name + >; updateUserStatus: FunctionReference< "mutation", "internal", - { isUserSet: boolean; status: string }, + { + isUserSet: boolean; + status: "online" | "busy" | "offline" | "away"; + }, any, Name >; diff --git a/convex/betterAuth/olm/index.ts b/convex/betterAuth/olm/index.ts index f8b0331..e3b7f10 100644 --- a/convex/betterAuth/olm/index.ts +++ b/convex/betterAuth/olm/index.ts @@ -1,6 +1,6 @@ import { v } from "convex/values"; import { Id } from "../../_generated/dataModel"; -import { mutation, query } from "../../_generated/server"; +import { mutation, query } from "../_generated/server"; export const sendKeysToServer = mutation({ args: { @@ -16,11 +16,10 @@ export const sendKeysToServer = mutation({ forceInsert: v.boolean(), // if true, insert even if user already has an olm account }, handler: async (ctx, args) => { - console.log("sendKeysToServer", args); + // check if user already has an olm account - // @ts-ignore const olmAccount = await ctx.db.query("olmAccount").withIndex("userId", (q) => q.eq("userId", args.userId)).first(); - console.log("olmAccount", olmAccount); + if (olmAccount && !args.forceInsert) { throw new Error("User already has an olm account"); } @@ -42,9 +41,8 @@ export const retrieveServerOlmAccount = query({ }, handler: async (ctx, args) => { const olmAccount = await ctx.db.get<"olmAccount">(args.userId as Id<"olmAccount">); - if (olmAccount) { - return olmAccount; - } + if (olmAccount) return olmAccount; + return null; }, }); \ No newline at end of file diff --git a/convex/betterAuth/schema.ts b/convex/betterAuth/schema.ts index c444520..2047f57 100644 --- a/convex/betterAuth/schema.ts +++ b/convex/betterAuth/schema.ts @@ -4,33 +4,43 @@ import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; +import { user } from "./schemas/user"; + +const Attachment = v.object({ + contentType: v.string(), // MIME type + description: v.union(v.null(), v.string()), // Description + ephemeral: v.boolean(), // Whether the attachment is ephemeral + height: v.optional(v.number()), // Height in pixels + width: v.optional(v.number()), // Width in pixels + id: v.id("storage"), // Storage ID + size: v.number(), // Size in bytes + spoiler: v.boolean(), // Whether the attachment is a spoiler + url: v.string(), // Public URL +}); + +const Message = v.object({ + inGuild: v.optional(v.boolean()), + attachments: v.optional(v.array(v.id("attachments"))), + authorId: v.id("user"), + channelId: v.id("channel"), + content: v.string(), + createdAt: v.string(), + createdTimestamp: v.number(), + editedAt: v.optional(v.string()), + guildId: v.optional(v.id("guild")), + id: v.string(), + nonce: v.optional(v.string()), + position: v.optional(v.number()), + referencedMessage: v.optional( + v.union(v.null(), v.id("messages"), v.id("channel"), v.id("guild")), + ), + url: v.optional(v.string()), +}) export const tables = { - user: defineTable({ - name: v.string(), - email: v.string(), - emailVerified: v.boolean(), - image: v.optional(v.union(v.null(), v.string())), - createdAt: v.number(), - updatedAt: v.number(), - userId: v.optional(v.union(v.null(), v.string())), - username: v.optional(v.union(v.null(), v.string())), - displayUsername: v.optional(v.union(v.null(), v.string())), - metadata: v.optional(v.object({ - phrasePreference: v.union(v.literal("comforting"), v.literal("mocking"), v.literal("both")), - })), - status: v.optional(v.object({ - status: v.union(v.literal("online"), v.literal("busy"), v.literal("offline"), v.literal("away")), - isUserSet: v.boolean(), - })), - friends: v.optional(v.array(v.string())), - }) - .index("email_name", ["email", "name"]) - .index("name", ["name"]) - .index("userId", ["userId"]) - .index("username", ["username"]) - .index("status", ["status"]) - .index("friends", ["friends"]), + ...user, + messages: defineTable(Message), + attachments: defineTable(Attachment), session: defineTable({ expiresAt: v.number(), token: v.string(), @@ -87,7 +97,9 @@ export const tables = { publicKey: v.string(), })), }) - .index("userId", ["userId"]), + .index("userId", ["userId"]) + .index("userId_keys", ["userId", "oneTimeKeys"]) + .index("userId_identityKey", ["userId", "identityKey"]), }; const schema = defineSchema(tables); diff --git a/convex/betterAuth/schemas/user.ts b/convex/betterAuth/schemas/user.ts new file mode 100644 index 0000000..d4aa349 --- /dev/null +++ b/convex/betterAuth/schemas/user.ts @@ -0,0 +1,55 @@ +import { defineTable } from "convex/server"; +import { v } from "convex/values"; + +export const user = { + user: defineTable({ + name: v.string(), + email: v.string(), + emailVerified: v.boolean(), + image: v.optional(v.union(v.null(), v.string())), + createdAt: v.number(), + updatedAt: v.number(), + userId: v.optional(v.union(v.null(), v.string())), + username: v.optional(v.union(v.null(), v.string())), + displayUsername: v.optional(v.union(v.null(), v.string())), + metadata: v.optional(v.object({ + phrasePreference: v.union(v.literal("comforting"), v.literal("mocking"), v.literal("both")), + })), + }) + .index("email_name", ["email", "name"]) + .index("byName", ["name"]) + .index("userId", ["userId"]) + .index("username", ["username"]), + userStatus: defineTable({ + userId: v.id("user"), + status: v.union(v.literal("online"), v.literal("busy"), v.literal("offline"), v.literal("away")), + isUserSet: v.boolean(), + updatedAt: v.number(), + }) + .index("userId", ["userId"]) + .index("status", ["status"]), + friendRequests: defineTable({ + userId: v.id("user"), + requestTo: v.id("user"), + method: v.union(v.literal("receive"), v.literal("send")), + requestId: v.string(), + createdAt: v.number(), + expiresAt: v.optional(v.number()), + acceptedAt: v.optional(v.number()), + declinedAt: v.optional(v.number()), + ignoredAt: v.optional(v.number()), + }) + .index("userId_method", ["userId", "method"]) + .index("userId", ["userId"]) + .index("requestId", ["requestId"]) + .index("requestTo", ["requestTo"]) + .index("expiresAt", ["expiresAt"]), + friends: defineTable({ + userId: v.id("user"), + friendId: v.id("user"), + createdAt: v.number(), + }) + .index("userId", ["userId"]) + .index("friendId", ["friendId"]) + .index("userId_friendId", ["userId", "friendId"]), +} \ No newline at end of file diff --git a/convex/betterAuth/user/index.ts b/convex/betterAuth/user/index.ts index 3ad2f3d..51d417f 100644 --- a/convex/betterAuth/user/index.ts +++ b/convex/betterAuth/user/index.ts @@ -1,28 +1,320 @@ import { v } from "convex/values"; -import { Id } from "../../_generated/dataModel"; -import { mutation } from "../../_generated/server"; +import { Id } from "../_generated/dataModel"; +import { mutation, MutationCtx, query, QueryCtx } from "../_generated/server"; + +async function userValidation(ctx: MutationCtx | QueryCtx) { + const user = await ctx.auth.getUserIdentity(); + if (!user) { + throw new Error("User not found"); + } + + const userId = ctx.db.normalizeId("user", user.subject as string) as Id<"user">; + if (!userId) { + throw new Error("User not found"); + } + + return { + userId, + user, + } +} export const updateUserStatus = mutation({ args: { - status: v.string(), + status: v.union(v.literal("online"), v.literal("busy"), v.literal("offline"), v.literal("away")), isUserSet: v.boolean(), }, handler: async (ctx, args) => { - const user = await ctx.auth.getUserIdentity(); - if (!user) { - throw new Error("User not found"); - } + try { + const { userId } = await userValidation(ctx); - const userId = ctx.db.normalizeId("user", user.subject as string) as Id<"user">; - if (!userId) { - throw new Error("User not found"); + // Check if user status is already set + const userStatus = await ctx.db.query("userStatus").withIndex("userId", (q) => q.eq("userId", userId)).first(); + if (userStatus) { + await ctx.db.patch(userStatus._id, { + status: args.status, + isUserSet: args.isUserSet, + updatedAt: Date.now(), + }); + } else { + await ctx.db.insert("userStatus", { + userId: userId, + status: args.status, + isUserSet: false, + updatedAt: Date.now(), + }); + } + return { success: true, message: "User status updated successfully" }; + } catch (error) { + console.error("Error updating user status:", error); + throw new Error("Failed to update user status"); } + }, +}); - return ctx.db.patch<"user">("user", userId, { - status: { - status: args.status, - isUserSet: args.isUserSet, - }, +export const getUserStatus = query({ + handler: async (ctx) => { + const { userId } = await userValidation(ctx); + const userStatus = await ctx.db.query("userStatus").withIndex("userId", (q) => q.eq("userId", userId)).first(); + return userStatus; + } +}); + +export const updateUserMetadata = mutation({ + args: { + metadata: v.object({ + phrasePreference: v.union(v.literal("comforting"), v.literal("mocking"), v.literal("both")), + }), + }, + handler: async (ctx, args) => { + const { userId } = await userValidation(ctx); + + return ctx.db.patch("user", userId, { + metadata: args.metadata, }); }, -}); \ No newline at end of file +}); + +export const sendFriendRequest = mutation({ + args: { + username: v.string(), + }, + handler: async (ctx, args) => { + const { userId, user: currentUser } = await userValidation(ctx); + + // Find the target user + const targetUser = await ctx.db.query("user").withIndex("byName", (q) => q.eq("name", args.username)).first(); + if (!targetUser) { + throw new Error("User not found"); + } + + // Check if trying to send request to yourself + if (targetUser._id === userId) { + throw new Error("You cannot send a friend request to yourself"); + } + + // Check if already friends + const existingFriendship = await ctx.db + .query("friends") + .withIndex("userId_friendId", (q) => q.eq("userId", userId).eq("friendId", targetUser._id)) + .first(); + + if (existingFriendship) { + throw new Error("You are already friends with this user"); + } + + // Check for existing requests in both directions + const existingRequests = await ctx.db + .query("friendRequests") + .filter((q) => + q.or( + q.and( + q.eq(q.field("userId"), userId), + q.eq(q.field("requestTo"), targetUser._id) + ), + q.and( + q.eq(q.field("userId"), targetUser._id), + q.eq(q.field("requestTo"), userId) + ) + ) + ) + .filter((q) => q.eq(q.field("acceptedAt"), undefined)) + .filter((q) => q.eq(q.field("declinedAt"), undefined)) + .collect(); + + const existingSentRequest = existingRequests.find(r => r.userId === userId); + const incomingRequest = existingRequests.find(r => r.userId === targetUser._id); + + if (existingSentRequest) { + throw new Error("You have already sent a friend request to this user"); + } + + if (incomingRequest) { + const timestamp = Date.now(); + + // Auto-accept the incoming request + await ctx.db.patch(incomingRequest._id, { + acceptedAt: timestamp, + }); + + // Create bidirectional friendship entries + await Promise.all([ + ctx.db.insert("friends", { + userId: userId, + friendId: targetUser._id, + createdAt: timestamp, + }), + ctx.db.insert("friends", { + userId: targetUser._id, + friendId: userId, + createdAt: timestamp, + }), + ]); + + return { + success: true, + message: "Friend request accepted automatically (they had already sent you a request)", + }; + } + + // Create the friend request (single row) + const requestId = crypto.randomUUID(); + await ctx.db.insert("friendRequests", { + userId: userId, + requestTo: targetUser._id, + method: "send", + requestId, + createdAt: Date.now(), + }); + + return { + success: true, + message: "Friend request sent successfully", + }; + } +}) + +export const answerFriendRequest = mutation({ + args: { + requestId: v.string(), + answer: v.union(v.literal("accept"), v.literal("decline"), v.literal("ignore")), + }, + handler: async (ctx, args) => { + const { userId } = await userValidation(ctx); + + // Get the friend request + const request = await ctx.db + .query("friendRequests") + .withIndex("requestId", (q) => q.eq("requestId", args.requestId)) + .first(); + + if (!request) { + throw new Error("Request not found"); + } + + // Verify current user is the recipient + if (request.requestTo !== userId) { + throw new Error("You are not the recipient of this request"); + } + + // Check if already answered + if (request.acceptedAt || request.declinedAt || request.ignoredAt) { + throw new Error("Request already answered"); + } + + const timestamp = Date.now(); + + // Update the request based on the answer + switch (args.answer) { + case "accept": + // Update request status + await ctx.db.patch(request._id, { acceptedAt: timestamp }); + + // Create bidirectional friendship entries + await Promise.all([ + ctx.db.insert("friends", { + userId: userId, + friendId: request.userId, + createdAt: timestamp, + }), + ctx.db.insert("friends", { + userId: request.userId, + friendId: userId, + createdAt: timestamp, + }), + ]); + break; + + case "decline": + await ctx.db.patch(request._id, { declinedAt: timestamp }); + break; + + case "ignore": + await ctx.db.patch(request._id, { ignoredAt: timestamp }); + break; + } + + return { + success: true, + message: `Friend request ${args.answer}ed successfully`, + }; + } +}) + +export const getFriendRequests = query({ + handler: async (ctx) => { + const { userId } = await userValidation(ctx); + + // Get all unanswered requests involving this user (sent by them OR sent to them) + const allRequests = await ctx.db + .query("friendRequests") + .filter((q) => + q.or( + q.eq(q.field("userId"), userId), // Requests sent by me + q.eq(q.field("requestTo"), userId) // Requests sent to me + ) + ) + .filter((q) => q.eq(q.field("acceptedAt"), undefined)) + .filter((q) => q.eq(q.field("declinedAt"), undefined)) + .filter((q) => q.eq(q.field("ignoredAt"), undefined)) + .collect(); + + // Transform to include method field based on perspective + const requestsWithMethod = await Promise.all( + allRequests.map(async (request) => { + const isSentByMe = request.userId === userId; + const otherUserId = isSentByMe ? request.requestTo : request.userId; + const otherUser = await ctx.db.get(otherUserId); + + return { + id: request.requestId, + _id: request._id, + userId: otherUserId, + username: otherUser?.username || otherUser?.displayUsername || otherUser?.name || "Unknown", + avatar: otherUser?.image || "", + createdAt: request.createdAt, + method: isSentByMe ? "send" : "receive", + }; + }) + ); + + return requestsWithMethod; + } +}) + +export const getFriends = query({ + handler: async (ctx) => { + const { userId } = await userValidation(ctx); + + // Get all friendships for this user + const friendships = await ctx.db + .query("friends") + .withIndex("userId", (q) => q.eq("userId", userId)) + .collect(); + + // Populate friend data with relevant fields + const friends = await Promise.all( + friendships.map(async (friendship) => { + const friend = await ctx.db.get(friendship.friendId); + const friendStatus = await ctx.db.query("userStatus").withIndex("userId", (q) => q.eq("userId", friendship.friendId)).first(); + if (!friend) return null; + + return { + _id: friend._id, + id: friend._id, + name: friend.name, + username: friend.username, + displayUsername: friend.displayUsername, + image: friend.image, + friendshipCreatedAt: friendship.createdAt, + status: friendStatus ? { + status: friendStatus.status, + isUserSet: friendStatus.isUserSet, + } : null, + }; + }) + ); + + return friends.filter(Boolean); + } +}) \ No newline at end of file diff --git a/package.json b/package.json index 72af770..55eec08 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "start:server": "NODE_ENV=development tsx src/server.ts" }, "dependencies": { + "@better-fetch/fetch": "^1.1.21", "@convex-dev/better-auth": "^0.10.4", "@marsidev/react-turnstile": "^1.4.0", "@matrix-org/olm": "^3.2.15", diff --git a/src/app/page.tsx b/src/app/page.tsx index 13a67b3..717bcf4 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,5 +1,6 @@ "use client" import AppSidebar from "@/components/home"; +import FriendRequestModal from "@/components/home/modals/friendRequest"; import OlmSetupDialog from "@/components/olm/olm-setup-dialog"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; @@ -9,11 +10,10 @@ import { useOlmSetup } from "@/hooks/use-olm-setup"; import { useSocket } from "@/hooks/use-socket"; import { authClient } from "@/lib/auth/client"; import { useMutation, useQuery } from "convex/react"; -import { PlusIcon, SearchIcon, UsersIcon } from "lucide-react"; +import { PlusIcon, SearchIcon, SettingsIcon, UsersIcon } from "lucide-react"; import { redirect } from "next/navigation"; import { useCallback, useEffect, useState } from "react"; import { api } from "../../convex/_generated/api"; - const mockPhrases = [ "No bitches? Womp womp", "You're all alone", @@ -94,9 +94,9 @@ const comfortingPhrases = [ ]; export default function Home() { - const { data, error, isPending } = authClient.useSession(); + const { data, error, isPending, refetch } = authClient.useSession(); - const [page, setPage] = useState<"friends" | "settings">("friends"); + const [page, setPage] = useState<"friends" | "support">("friends"); const [currentChannel, setCurrentChannel] = useState(null); const [openDmChannels, setOpenDmChannels] = useState([]); const [availableServers, setAvailableServers] = useState([]); @@ -104,29 +104,55 @@ export default function Home() { // Friends page state const [friendsPage, setFriendsPage] = useState<"all" | "available">("all"); const [friendsSearch, setFriendsSearch] = useState(""); + const [friendModal, setFriendModal] = useState(false); const hasServerOlm = useQuery( api.auth.retrieveServerOlmAccount, data?.user?.id ? { userId: data.user.id } : "skip" ); + // Get user status from separate table + const userStatus = useQuery(api.auth.getUserStatus); + + // Get friends list (reactive) + const friends = useQuery(api.auth.getFriends); + + // Type for friends + type Friend = NonNullable[number]; + // Mutation for sending keys to server const sendKeysToServer = useMutation(api.auth.sendKeysToServer); - const updateUserStatus = useMutation(api.auth.updateUserStatus); + + const updateUserMetadata = useMutation(api.auth.updateUserMetadata); useEffect(() => { if (!data) return; - const status = data.user.status - if (!status) return; - - if (status.status === "offline" && !status.isUserSet) { - updateUserStatus({ status: "online", isUserSet: false }); + const metadata = data.user.metadata + if (!metadata) { + console.debug( + "[Home] > User metadata set", + data.user.metadata + ) + updateUserMetadata({ metadata: { phrasePreference: "comforting" } }); + return } - }, [data?.user?.id, updateUserStatus, data?.user?.status]); + }, [data, updateUserMetadata]); // Custom hooks for socket and OLM management - const { socketStatus, socketInfo } = useSocket(data?.user?.id); + const { socketStatus, socketInfo, disconnect, connect } = useSocket({ + user: { + id: data?.user?.id, + status: userStatus ? { + status: userStatus.status, + isUserSet: userStatus.isUserSet, + } : { + status: "offline" as const, + isUserSet: false, + }, + }, + refetchUser: refetch + }); const { olmStatus, showOlmModal, setShowOlmModal, handleCreateAccount } = useOlmSetup({ userId: data?.user?.id, hasServerOlm, @@ -140,10 +166,9 @@ export default function Home() { } if (error || !data) { - return redirect(`/auth${error ? `?error=${error.cause}` : "no-data"}`); + return redirect(`/auth${error ? `?error=${error.cause}` : "?error=no-data"}`); } - const getRandomPhrase = useCallback(() => { const phrases = { comforting: comfortingPhrases, @@ -161,7 +186,7 @@ export default function Home() { return ( <> - +
{/* Header - fixed height and sticky */}
@@ -183,24 +208,28 @@ export default function Home() { }
{/* Page title/options */} -
-
- - Friends -
- -
- - - -
-
+ { + page === "friends" ? ( +
+
+ + Friends +
+ +
+ + + +
+
+ ) : null + }
{/* Content Area - Channel List + Main Content */}
@@ -213,6 +242,10 @@ export default function Home() { Friends +
@@ -269,19 +302,42 @@ export default function Home() { /> { friendsPage === "all" ? ( -
- All Friends +
+ All Friends • {friends ? friends.length : 0} + { + friends && friends.length > 0 ? ( + friends.map((friend: Friend) => { + if (!friend) return null; + + return ( +
+ {friend.displayUsername || friend.username || friend.name} +
+ ) + }) + ) : ( + + {getRandomPhrase()} + + ) + }
) : (
- Available Friends • {data.user.friends && data.user.friends.length > 0 ? data.user.friends.length : 0} + Available Friends • {friends ? friends.filter((f: Friend) => f && f.status?.status !== "offline").length : 0} { - data.user.friends && data.user.friends.length > 0 ? ( - data.user.friends.map((friend) => ( -
- {friend} -
- )) + friends && friends.length > 0 ? ( + friends + .filter((f: Friend) => f && f.status?.status !== "offline") + .map((friend: Friend) => { + if (!friend) return null; + + return ( +
+ {friend.displayUsername || friend.username || friend.name} +
+ ) + }) ) : ( {getRandomPhrase()} @@ -293,7 +349,7 @@ export default function Home() { }
- ) : page === "settings" ? ( + ) : page === "support" ? (
Servers @@ -306,6 +362,10 @@ export default function Home() {
+ {/* OLM Account Setup/Sync Modal */} void; connectSocket: () => void }) { const [uptime, setUptime] = useState("0s"); const [isOpen, setIsOpen] = useState(false); @@ -188,10 +189,21 @@ export default function ConnectionStatusIndicator({ socketStatus, socketInfo }:
{/* Footer hint */} -
+

Real-time connection via Socket.IO

+
diff --git a/src/components/home/index.tsx b/src/components/home/index.tsx index 79f348a..b4e3f6d 100644 --- a/src/components/home/index.tsx +++ b/src/components/home/index.tsx @@ -32,7 +32,7 @@ const SidebarItems: SiPher.SidebarItem[] = [ * It also is the controller for everything on the app, including going to other pages, showing conversations and other. * @param children - The children to be rendered in the sidebar inset */ -export default function AppSidebar({ children, socketStatus, socketInfo, currentChannel }: SiPher.AppSidebarProps) { +export default function AppSidebar({ children, socketStatus, socketInfo, currentChannel, disconnectSocket, connectSocket }: SiPher.AppSidebarProps) { const [activeItem, setActiveItem] = useState("home"); return ( @@ -120,7 +120,7 @@ export default function AppSidebar({ children, socketStatus, socketInfo, current }
{/* Socket connection status */} - +
{/* Spacer for centering on mobile */} diff --git a/src/components/home/modals/friendRequest.tsx b/src/components/home/modals/friendRequest.tsx new file mode 100644 index 0000000..67165b8 --- /dev/null +++ b/src/components/home/modals/friendRequest.tsx @@ -0,0 +1,305 @@ +"use client" + +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; +import { useMutation, useQuery } from "convex/react"; +import { CheckIcon, UserPlusIcon, XIcon } from "lucide-react"; +import { useEffect, useState } from "react"; +import { toast } from "sonner"; +import { api } from "../../../../convex/_generated/api"; + +interface FriendRequestModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export default function FriendRequestModal({ + open, + onOpenChange, +}: FriendRequestModalProps) { + + const getFriendRequests = useQuery(api.auth.getFriendRequests); + const sendFriendRequest = useMutation(api.auth.sendFriendRequest); + const answerFriendRequest = useMutation(api.auth.answerFriendRequest); + + const [username, setUsername] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState<"send" | "pending" | "sent">("send"); + const [pendingRequests, setPendingRequests] = useState([]); + const [sentRequests, setSentRequests] = useState([]); + + useEffect(() => { + if (getFriendRequests) { + + if (!getFriendRequests || getFriendRequests.length === 0) { + console.debug("[FriendRequestModal] > Such a sad day, no friend requests found") + setPendingRequests([]); + setSentRequests([]); + return; + } + + console.debug("[FriendRequestModal] > This guy is important, look at him with his big friend request list (¬.¬) :", getFriendRequests); + setPendingRequests(getFriendRequests.filter((request: any) => request.method === "receive")); + setSentRequests(getFriendRequests.filter((request: any) => request.method === "send")); + } + }, [getFriendRequests]); + + const handleSendRequest = async () => { + if (!username.trim()) return; + + setIsLoading(true); + setError(null); + + try { + + await sendFriendRequest({ + username: username, + }); + toast.success("Friend request sent successfully"); + setUsername(""); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to send friend request"); + } finally { + setIsLoading(false); + } + }; + + const handleAccept = async (requestId: string) => { + setIsLoading(true); + try { + await answerFriendRequest({ + requestId: requestId, + answer: "accept", + }); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to accept request"); + } finally { + setIsLoading(false); + } + }; + + const handleDecline = async (requestId: string) => { + setIsLoading(true); + try { + await answerFriendRequest({ + requestId: requestId, + answer: "decline", + }); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to decline request"); + } finally { + setIsLoading(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && !isLoading) { + handleSendRequest(); + } + }; + + const formatTimeAgo = (timestamp: number) => { + const seconds = Math.floor((Date.now() - timestamp) / 1000); + if (seconds < 60) return "just now"; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + return `${days}d ago`; + }; + + return ( + + + + + + Friend Requests + + + Send, accept, or manage your friend requests. + + + + {/* Tabs */} +
+ + + +
+ + {/* Content */} +
+ {activeTab === "send" && ( +
+
+ setUsername(e.target.value)} + onKeyDown={handleKeyDown} + disabled={isLoading} + /> + {error && ( +

{error}

+ )} +
+ +
+ )} + + {activeTab === "pending" && ( +
+ {pendingRequests.length === 0 ? ( +
+

+ No pending friend requests +

+
+ ) : ( + pendingRequests.map((request) => ( +
+
+ + + + {request.username.charAt(0).toUpperCase()} + + +
+ + {request.username} + + + {formatTimeAgo(request.createdAt)} + +
+
+
+ + +
+
+ )) + )} +
+ )} + + {activeTab === "sent" && ( +
+ {sentRequests.length === 0 ? ( +
+

+ No sent friend requests +

+
+ ) : ( + sentRequests.map((request) => ( +
+
+ + + + {request.username.charAt(0).toUpperCase()} + + +
+ + {request.username} + + + Sent {formatTimeAgo(request.createdAt)} + +
+
+ + Pending... + +
+ )) + )} +
+ )} +
+ + + + +
+
+ ); +} + diff --git a/src/components/socket-test.tsx b/src/components/socket-test.tsx deleted file mode 100644 index b2c20ec..0000000 --- a/src/components/socket-test.tsx +++ /dev/null @@ -1,93 +0,0 @@ -"use client" - -import { useEffect, useState } from "react" -import { io, Socket } from "socket.io-client" -import { Button } from "./ui/button" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "./ui/card" -import { Input } from "./ui/input" - -export default function SocketTest() { - const [socket, setSocket] = useState(null) - const [isConnected, setIsConnected] = useState(false) - const [messages, setMessages] = useState([]) - const [inputMessage, setInputMessage] = useState("") - - useEffect(() => { - // Initialize Socket.IO client - const socketInstance = io() - - socketInstance.on("connect", () => { - console.log("Connected to Socket.IO:", socketInstance.id) - setIsConnected(true) - setMessages(prev => [...prev, `✅ Connected: ${socketInstance.id}`]) - }) - - socketInstance.on("disconnect", (reason) => { - console.log("Disconnected:", reason) - setIsConnected(false) - setMessages(prev => [...prev, `❌ Disconnected: ${reason}`]) - }) - - socketInstance.on("message", (data) => { - console.log("Message received:", data) - setMessages(prev => [...prev, `📩 Received: ${data}`]) - }) - - setSocket(socketInstance) - - return () => { - socketInstance.disconnect() - } - }, []) - - const sendMessage = () => { - if (socket && inputMessage.trim()) { - socket.emit("message", inputMessage) - setMessages(prev => [...prev, `📤 Sent: ${inputMessage}`]) - setInputMessage("") - } - } - - return ( - - - Socket.IO Test Client - - Status: {isConnected ? ( - 🟢 Connected - ) : ( - 🔴 Disconnected - )} - - - -
- setInputMessage(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && sendMessage()} - disabled={!isConnected} - /> - -
- -
-
- {messages.length === 0 ? ( -

No messages yet...

- ) : ( - messages.map((msg, idx) => ( -

{msg}

- )) - )} -
-
-
-
- ) -} - diff --git a/src/components/ui/user/floating-card.tsx b/src/components/ui/user/floating-card.tsx index 8369d9b..6c18aa3 100644 --- a/src/components/ui/user/floating-card.tsx +++ b/src/components/ui/user/floating-card.tsx @@ -6,7 +6,9 @@ import { GearSix, MicrophoneSlash } from "@phosphor-icons/react"; +import { useQuery } from "convex/react"; import { useEffect, useRef, useState } from "react"; +import { api } from "../../../../convex/_generated/api"; import { Avatar, AvatarFallback, AvatarImage } from "../avatar"; import { Button } from "../button"; import { HoverCard, HoverCardContent, HoverCardTrigger } from "../hover-card"; @@ -22,19 +24,21 @@ interface UserFloatingCardProps { const statusColors: Record = { online: "bg-emerald-500", - busy: "bg-amber-500", + busy: "bg-red-500", away: "bg-yellow-500", offline: "bg-muted-foreground" }; -export default function UserFloatingCard({ - user, -}: UserFloatingCardProps) { +export default function UserFloatingCard( + { user }: UserFloatingCardProps +) { const [cardOpen, setCardOpen] = useState(false); const triggerRef = useRef(null); const contentRef = useRef(null); - const status = user.status?.status; - const activity = user.status?.activity; + const status = useQuery(api.auth.getUserStatus) as { + status: "online" | "busy" | "offline" | "away"; + isUserSet: boolean; + } | null; // Close when clicking outside the trigger/content useEffect(() => { @@ -113,7 +117,7 @@ export default function UserFloatingCard({
@@ -124,19 +128,12 @@ export default function UserFloatingCard({ {user.name} - {activity ? ( -
- {"\u2022"} - - {activity} - -
- ) : ( -
- {"\u2022"} - Activity status (coming soon) -
- )} + +
+ {"\u2022"} + Activity status (coming soon) +
+ @@ -156,10 +153,8 @@ export default function UserFloatingCard({
{user.name} - {status} - - {activity ?? "Activity status (coming soon)"} - + {status?.status} +
diff --git a/src/hooks/use-socket.ts b/src/hooks/use-socket.ts index c88e7ef..c119882 100644 --- a/src/hooks/use-socket.ts +++ b/src/hooks/use-socket.ts @@ -1,9 +1,26 @@ "use client" -import { useEffect, useState } from "react"; +import { useMutation } from "convex/react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { io, Socket } from "socket.io-client"; +import { api } from "../../convex/_generated/api"; + +interface UseSocketProps { + user: { + id?: string; + status: { + status: "online" | "busy" | "offline" | "away"; + isUserSet: boolean; + } + } + refetchUser: () => void; +} + +export function useSocket({ user, refetchUser }: UseSocketProps) { + const updateUserStatus = useMutation(api.auth.updateUserStatus); + const socketRef = useRef(null); + const pingIntervalRef = useRef(null); -export function useSocket(userId: string | undefined) { const [socketStatus, setSocketStatus] = useState("connecting"); const [socketInfo, setSocketInfo] = useState({ ping: null, @@ -14,21 +31,81 @@ export function useSocket(userId: string | undefined) { error: null }); + // Manual disconnect function + const disconnect = useCallback(() => { + if (socketRef.current) { + console.log("🔌 Manually disconnecting socket..."); + socketRef.current.disconnect(); + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current); + pingIntervalRef.current = null; + } + setSocketStatus("disconnected"); + } + }, []); + + const connect = useCallback(() => { + if (socketRef.current) { + socketRef.current.connect(); + refetchUser(); + } + }, [refetchUser]); + useEffect(() => { - if (!userId) return; + if (!user.id) return; const socket: Socket = io({ withCredentials: false }); - let pingInterval: NodeJS.Timeout | null = null; + socketRef.current = socket; - // Measure ping latency + // Measure ping latency using acknowledgment callback const measurePing = () => { - const start = Date.now(); - socket.volatile.emit("ping", () => { - const latency = Date.now() - start; + const clientTimestamp = Date.now(); + + // Use acknowledgment callback for reliable latency measurement + socket.timeout(5000).emit("ping", (err: Error, serverTimestamp: number) => { + if (err) { + console.warn("[Socket] Ping timeout or error:", err); + setSocketInfo((prev: SiPher.SocketInfo) => ({ ...prev, ping: null })); + return; + } + + const now = Date.now(); + const latency = now - clientTimestamp; + console.log("[Socket] Ping latency:", latency); setSocketInfo((prev: SiPher.SocketInfo) => ({ ...prev, ping: latency })); }); }; + function setUserDefaultStatus( + newStatus: "online" | "busy" | "offline" | "away", + oldStatus?: { + status: "online" | "busy" | "offline" | "away"; + isUserSet: boolean; + } + ) { + if (!oldStatus) { + console.log("🔌 User default status set to online"); + updateUserStatus({ status: "online", isUserSet: false }); + refetchUser(); + return; + } + + if (newStatus === "offline") { + updateUserStatus({ status: newStatus, isUserSet: oldStatus.isUserSet }); + refetchUser(); + return; + } else if (!oldStatus.isUserSet) { + console.log("🔌 User default status set to online"); + updateUserStatus({ status: newStatus, isUserSet: oldStatus.isUserSet }); + refetchUser(); + return; + } else { + updateUserStatus({ status: oldStatus.status, isUserSet: oldStatus.isUserSet }); + refetchUser(); + return; + } + } + socket.on("connect", () => { console.log("✅ Connected to socket - Authentication successful!"); setSocketStatus("connected"); @@ -41,9 +118,11 @@ export function useSocket(userId: string | undefined) { error: null })); - // Start ping measurement every 5 seconds + setUserDefaultStatus("online", user.status); + + // Start ping measurement every 5 seconds for latency display measurePing(); - pingInterval = setInterval(measurePing, 5000); + pingIntervalRef.current = setInterval(measurePing, 5000); }); // Update transport when it upgrades (polling -> websocket) @@ -53,6 +132,7 @@ export function useSocket(userId: string | undefined) { socket.on("connect_error", (err) => { console.error("❌ Socket connection error:", err.message); + setUserDefaultStatus("offline", user.status); setSocketStatus("error"); setSocketInfo((prev: SiPher.SocketInfo) => ({ ...prev, @@ -65,6 +145,7 @@ export function useSocket(userId: string | undefined) { socket.on("disconnect", (reason) => { console.log("🔌 Disconnected from socket:", reason); + setUserDefaultStatus("offline", user.status); setSocketStatus("disconnected"); setSocketInfo((prev: SiPher.SocketInfo) => ({ ...prev, @@ -72,7 +153,10 @@ export function useSocket(userId: string | undefined) { connectedAt: null, error: reason })); - if (pingInterval) clearInterval(pingInterval); + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current); + pingIntervalRef.current = null; + } }); // Handle pong response for ping measurement @@ -81,11 +165,14 @@ export function useSocket(userId: string | undefined) { }); return () => { - if (pingInterval) clearInterval(pingInterval); + if (pingIntervalRef.current) { + clearInterval(pingIntervalRef.current); + pingIntervalRef.current = null; + } socket.disconnect(); }; - }, [userId]); + }, [user.id, updateUserStatus]); - return { socketStatus, socketInfo }; + return { socketStatus, socketInfo, disconnect, connect }; } diff --git a/src/lib/auth/client.ts b/src/lib/auth/client.ts index c2c057a..762786e 100644 --- a/src/lib/auth/client.ts +++ b/src/lib/auth/client.ts @@ -8,7 +8,7 @@ export const authClient = createAuthClient({ convexClient(), usernameClient(), oneTimeTokenClient(), - inferAdditionalFields() + inferAdditionalFields(), ], sessionOptions: { refetchOnWindowFocus: false, diff --git a/src/lib/sockets/events/dm.ts b/src/lib/sockets/events/dm.ts index 6d35e36..140c750 100644 --- a/src/lib/sockets/events/dm.ts +++ b/src/lib/sockets/events/dm.ts @@ -26,7 +26,7 @@ interface DmMessage { const dmEvent: SiPher.EventsType = { name: "dm", - description: "Send a direct message to another user", + description: "Send a direct message to another user using the client-side encryption", category: "user", type: "message", handler: (socket: Socket, io: SocketIOServer, data: DmMessage) => { diff --git a/src/lib/sockets/events/message.ts b/src/lib/sockets/events/message.ts index d0f63a8..8d348a5 100644 --- a/src/lib/sockets/events/message.ts +++ b/src/lib/sockets/events/message.ts @@ -5,7 +5,7 @@ export default { handler: (socket: Socket, io: SocketIOServer, ...args: any[]) => { console.log("Message received", args) }, - description: "A message event", - category: "user", + description: "Send a message to a channel by using the server-side encryption", + category: "server", type: "message" } satisfies SiPher.EventsType \ No newline at end of file diff --git a/src/lib/sockets/events/ping.ts b/src/lib/sockets/events/ping.ts new file mode 100644 index 0000000..01d1cea --- /dev/null +++ b/src/lib/sockets/events/ping.ts @@ -0,0 +1,24 @@ +/** + * @fileoverview Ping event handler for measuring latency and checking connection health + */ + +import type { Socket, Server as SocketIOServer } from "socket.io"; + +export default { + name: "ping", + description: "Handles client ping requests and returns pong with timestamp for latency measurement", + category: "system", + type: "custom", + handler: (socket: Socket, io: SocketIOServer, callback?: (serverTimestamp: number) => void) => { + const serverTimestamp = Date.now(); + + // Use acknowledgment callback if provided (more reliable than emit) + if (callback && typeof callback === "function") { + callback(serverTimestamp); + } else { + // Fallback to emit if no callback + socket.emit("pong", serverTimestamp); + } + } +} satisfies SiPher.EventsType; + diff --git a/src/lib/sockets/index.ts b/src/lib/sockets/index.ts index 68e7303..7a83e65 100644 --- a/src/lib/sockets/index.ts +++ b/src/lib/sockets/index.ts @@ -2,6 +2,7 @@ * @fileoverview Socket Manager Class for handling socket connections and events at the server side. */ +import { Session, User } from "better-auth"; import { existsSync, readdirSync } from "fs"; import type { Server as HTTPServer } from "http"; import path from "path"; @@ -41,7 +42,11 @@ export default class SocketManager { }; if (!this.socketIo) { - this.socketIo = new SocketIOServer(nextServer) + this.socketIo = new SocketIOServer(nextServer, { + // Configure Socket.IO's built-in heartbeat mechanism + pingInterval: 25000, // Server sends ping every 25 seconds + pingTimeout: 60000, // Close connection if no pong received within 60 seconds + }); } if (this.options.requireAuth) { @@ -54,7 +59,7 @@ export default class SocketManager { this.socketIo.use(async (socket, next) => { try { - let result: { user?: unknown; session?: unknown } | null = null; + let result: { user?: User, session?: Session } | null = null; if (this.options.authMethod === "ott") { // OTT-based auth: client must provide token in auth object @@ -94,14 +99,15 @@ export default class SocketManager { return next(new Error("Authentication error: Invalid session")); } - const user = result.user as { id: string; email: string; name?: string }; + const { user, session } = result; // Set socket.id to user ID for persistent identification - (socket as any).id = user.id; + // @ts-expect-error: This should be a readonly property, but IDGAF, if it breaks, it breaks :D + socket.id = user.id; // Attach user and session to socket for use in event handlers - (socket as any).user = user; - (socket as any).session = result.session; + socket.user = user; + socket.session = session; next(); } catch (error) { diff --git a/src/types/globals.d.ts b/src/types/globals.d.ts index 9ebf00e..eb04361 100644 --- a/src/types/globals.d.ts +++ b/src/types/globals.d.ts @@ -1,3 +1,4 @@ +import { Session, User } from "better-auth"; import { Socket, Server as SocketIOServer } from "socket.io"; declare global { @@ -111,16 +112,18 @@ declare global { id: string, system: System } + } - type MessageEvent = { - message: { - /** Will either be a raw string or a encrypted blob, if it is a encrypted blob, the iv will be provided */ - content: string, - iv?: string - }, - from: SipherUser, - recipient: MessageRecipient - } + // Add custom socket.io types +} + +// Extend Socket.io types to include authenticated user data +declare module "socket.io" { + interface Socket { + /** Authenticated user from Better Auth (set after auth middleware) */ + user?: User; + /** Session data from Better Auth (set after auth middleware) */ + session?: Session; } } diff --git a/src/types/messages/encrypted.d.ts b/src/types/messages/encrypted.d.ts new file mode 100644 index 0000000..316d546 --- /dev/null +++ b/src/types/messages/encrypted.d.ts @@ -0,0 +1,20 @@ +declare global { + declare namespace SiPher.Messages.ClientEncrypted { + type EncryptedMessage = { + content: string, + iv?: string + } + type MessageEvent = { + message: { + /** Will either be a raw string or a encrypted blob, if it is a encrypted blob, the iv will be provided */ + content: string, + iv?: string + }, + from: SipherUser, + recipient: MessageRecipient + } + } +} + +export { } + diff --git a/src/types/messages/unencrypted.d.ts b/src/types/messages/unencrypted.d.ts new file mode 100644 index 0000000..8ede999 --- /dev/null +++ b/src/types/messages/unencrypted.d.ts @@ -0,0 +1,24 @@ +import type { Collection, Doc } from "convex/server"; +import type { DMChannel, GlobalChannel, GuildChannel, RegionalChannel } from "./channels"; + +declare global { + declare namespace SiPher.Messages.ServerEncrypted { + type DBMessageType = Doc<"messages">; + type DBAttachmentType = Doc<"attachments">; + type ServerEncryptedMessage = Omit & { + author: SipherUser, + channel: GuildChannel | RegionalChannel | GlobalChannel | DMChannel, + guild: Server | null, + attachments: Collection, + } + + type ServerEncryptedMessageEvent = { + message: DBMessageType, + from: SipherUser, + recipient: MessageRecipient + } + } + +} +export { }; + diff --git a/src/types/sidebar.d.ts b/src/types/sidebar.d.ts index 3ce1b6c..916abc1 100644 --- a/src/types/sidebar.d.ts +++ b/src/types/sidebar.d.ts @@ -6,6 +6,8 @@ declare global { socketStatus: SocketStatus; socketInfo: SocketInfo; currentChannel?: SiPher.Channel; + disconnectSocket: () => void; + connectSocket: () => void; } interface SidebarItem {