Add friend request functionality and user status updates

- Implemented a modal for sending and managing friend requests, allowing users to send, accept, decline, or ignore requests.
- Enhanced user status management by integrating real-time updates for online, busy, offline, and away statuses.
- Updated the API and database schema to support new friend request and user status features.
- Improved socket management for better connection handling and user experience.
- Refactored UI components to accommodate new functionalities while maintaining consistency.
This commit is contained in:
Nixyan 2025-12-28 01:10:31 -03:00
parent 5198a12f9e
commit 45301ac52b
26 changed files with 2860 additions and 332 deletions

View file

@ -4,6 +4,7 @@
"": { "": {
"name": "sipher", "name": "sipher",
"dependencies": { "dependencies": {
"@better-fetch/fetch": "^1.1.21",
"@convex-dev/better-auth": "^0.10.4", "@convex-dev/better-auth": "^0.10.4",
"@marsidev/react-turnstile": "^1.4.0", "@marsidev/react-turnstile": "^1.4.0",
"@matrix-org/olm": "^3.2.15", "@matrix-org/olm": "^3.2.15",

View file

@ -62,22 +62,77 @@ export declare const components: {
displayUsername?: null | string; displayUsername?: null | string;
email: string; email: string;
emailVerified: boolean; emailVerified: boolean;
friends?: Array<string>;
image?: null | string; image?: null | string;
metadata?: { metadata?: {
phrasePreference: "comforting" | "mocking" | "both"; phrasePreference: "comforting" | "mocking" | "both";
}; };
name: string; name: string;
status?: {
isUserSet: boolean;
status: "online" | "busy" | "offline" | "away";
};
updatedAt: number; updatedAt: number;
userId?: null | string; userId?: null | string;
username?: null | string; username?: null | string;
}; };
model: "user"; 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<string>;
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: { data: {
createdAt: number; createdAt: number;
@ -158,8 +213,176 @@ export declare const components: {
| "username" | "username"
| "displayUsername" | "displayUsername"
| "metadata" | "metadata"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "userStatus";
where?: Array<{
connector?: "AND" | "OR";
field:
| "userId"
| "status" | "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<string>
| Array<number>
| 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<string>
| Array<number>
| 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<string>
| Array<number>
| 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<string>
| Array<number>
| null;
}>;
}
| {
model: "attachments";
where?: Array<{
connector?: "AND" | "OR";
field:
| "contentType"
| "description"
| "ephemeral"
| "height"
| "width"
| "id"
| "size"
| "spoiler"
| "url"
| "_id"; | "_id";
operator?: operator?:
| "lt" | "lt"
@ -371,8 +594,176 @@ export declare const components: {
| "username" | "username"
| "displayUsername" | "displayUsername"
| "metadata" | "metadata"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "userStatus";
where?: Array<{
connector?: "AND" | "OR";
field:
| "userId"
| "status" | "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<string>
| Array<number>
| 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<string>
| Array<number>
| 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<string>
| Array<number>
| 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<string>
| Array<number>
| null;
}>;
}
| {
model: "attachments";
where?: Array<{
connector?: "AND" | "OR";
field:
| "contentType"
| "description"
| "ephemeral"
| "height"
| "width"
| "id"
| "size"
| "spoiler"
| "url"
| "_id"; | "_id";
operator?: operator?:
| "lt" | "lt"
@ -563,6 +954,11 @@ export declare const components: {
limit?: number; limit?: number;
model: model:
| "user" | "user"
| "userStatus"
| "friendRequests"
| "friends"
| "messages"
| "attachments"
| "session" | "session"
| "account" | "account"
| "verification" | "verification"
@ -610,6 +1006,11 @@ export declare const components: {
{ {
model: model:
| "user" | "user"
| "userStatus"
| "friendRequests"
| "friends"
| "messages"
| "attachments"
| "session" | "session"
| "account" | "account"
| "verification" | "verification"
@ -654,16 +1055,11 @@ export declare const components: {
displayUsername?: null | string; displayUsername?: null | string;
email?: string; email?: string;
emailVerified?: boolean; emailVerified?: boolean;
friends?: Array<string>;
image?: null | string; image?: null | string;
metadata?: { metadata?: {
phrasePreference: "comforting" | "mocking" | "both"; phrasePreference: "comforting" | "mocking" | "both";
}; };
name?: string; name?: string;
status?: {
isUserSet: boolean;
status: "online" | "busy" | "offline" | "away";
};
updatedAt?: number; updatedAt?: number;
userId?: null | string; userId?: null | string;
username?: null | string; username?: null | string;
@ -681,8 +1077,225 @@ export declare const components: {
| "username" | "username"
| "displayUsername" | "displayUsername"
| "metadata" | "metadata"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "userStatus";
update: {
isUserSet?: boolean;
status?: "online" | "busy" | "offline" | "away";
updatedAt?: number;
userId?: string;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "userId"
| "status" | "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<string>
| Array<number>
| 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<string>
| Array<number>
| 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<string>
| Array<number>
| null;
}>;
}
| {
model: "messages";
update: {
attachments?: Array<string>;
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<string>
| Array<number>
| 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"; | "_id";
operator?: operator?:
| "lt" | "lt"
@ -926,16 +1539,11 @@ export declare const components: {
displayUsername?: null | string; displayUsername?: null | string;
email?: string; email?: string;
emailVerified?: boolean; emailVerified?: boolean;
friends?: Array<string>;
image?: null | string; image?: null | string;
metadata?: { metadata?: {
phrasePreference: "comforting" | "mocking" | "both"; phrasePreference: "comforting" | "mocking" | "both";
}; };
name?: string; name?: string;
status?: {
isUserSet: boolean;
status: "online" | "busy" | "offline" | "away";
};
updatedAt?: number; updatedAt?: number;
userId?: null | string; userId?: null | string;
username?: null | string; username?: null | string;
@ -953,8 +1561,225 @@ export declare const components: {
| "username" | "username"
| "displayUsername" | "displayUsername"
| "metadata" | "metadata"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "userStatus";
update: {
isUserSet?: boolean;
status?: "online" | "busy" | "offline" | "away";
updatedAt?: number;
userId?: string;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "userId"
| "status" | "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<string>
| Array<number>
| 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<string>
| Array<number>
| 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<string>
| Array<number>
| null;
}>;
}
| {
model: "messages";
update: {
attachments?: Array<string>;
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<string>
| Array<number>
| 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"; | "_id";
operator?: operator?:
| "lt" | "lt"
@ -1202,10 +2027,34 @@ export declare const components: {
}; };
user: { user: {
index: { 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< updateUserStatus: FunctionReference<
"mutation", "mutation",
"internal", "internal",
{ isUserSet: boolean; status: string }, {
isUserSet: boolean;
status: "online" | "busy" | "offline" | "away";
},
any any
>; >;
}; };

View file

@ -3,7 +3,6 @@ import { convex } from "@convex-dev/better-auth/plugins";
import { betterAuth, type BetterAuthOptions } from "better-auth"; import { betterAuth, type BetterAuthOptions } from "better-auth";
import { captcha, oneTimeToken, openAPI, username } from "better-auth/plugins"; import { captcha, oneTimeToken, openAPI, username } from "better-auth/plugins";
import { v } from "convex/values"; import { v } from "convex/values";
import { z } from "zod";
import { components } from "./_generated/api"; import { components } from "./_generated/api";
import { DataModel } from "./_generated/dataModel"; import { DataModel } from "./_generated/dataModel";
import { mutation, query } from "./_generated/server"; import { mutation, query } from "./_generated/server";
@ -23,15 +22,6 @@ export const authComponent = createClient<DataModel, typeof authSchema>(
} }
); );
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<DataModel>) => { export const createAuthOptions = (ctx: GenericCtx<DataModel>) => {
return { return {
baseURL: siteUrl, baseURL: siteUrl,
@ -45,45 +35,12 @@ export const createAuthOptions = (ctx: GenericCtx<DataModel>) => {
additionalFields: { additionalFields: {
metadata: { metadata: {
type: "json", type: "json",
defaultValue: () => {
const metadata = metadataSchema.parse({
phrasePreference: "comforting",
})
return metadata.phrasePreference;
},
required: false, required: false,
}, },
friends: { friends: {
type: "string[]", type: "string[]",
defaultValue: [],
required: false, required: false,
index: true 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<DataModel>) => {
} }
}), }),
oneTimeToken(), oneTimeToken(),
openAPI() openAPI(),
], ],
} satisfies BetterAuthOptions; } satisfies BetterAuthOptions;
} }
@ -159,7 +116,7 @@ export const retrieveServerOlmAccount = query({
export const updateUserStatus = mutation({ export const updateUserStatus = mutation({
args: { args: {
status: v.string(), status: v.union(v.literal("online"), v.literal("busy"), v.literal("offline"), v.literal("away")),
isUserSet: v.boolean(), isUserSet: v.boolean(),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
@ -169,3 +126,61 @@ export const updateUserStatus = mutation({
}); });
}, },
}); });
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)
},
});

View file

@ -11,6 +11,7 @@
import type * as adapter from "../adapter.js"; import type * as adapter from "../adapter.js";
import type * as auth from "../auth.js"; import type * as auth from "../auth.js";
import type * as olm_index from "../olm/index.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 * as user_index from "../user/index.js";
import type { import type {
@ -24,6 +25,7 @@ const fullApi: ApiFromModules<{
adapter: typeof adapter; adapter: typeof adapter;
auth: typeof auth; auth: typeof auth;
"olm/index": typeof olm_index; "olm/index": typeof olm_index;
"schemas/user": typeof schemas_user;
"user/index": typeof user_index; "user/index": typeof user_index;
}> = anyApi as any; }> = anyApi as any;

View file

@ -35,22 +35,77 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
displayUsername?: null | string; displayUsername?: null | string;
email: string; email: string;
emailVerified: boolean; emailVerified: boolean;
friends?: Array<string>;
image?: null | string; image?: null | string;
metadata?: { metadata?: {
phrasePreference: "comforting" | "mocking" | "both"; phrasePreference: "comforting" | "mocking" | "both";
}; };
name: string; name: string;
status?: {
isUserSet: boolean;
status: "online" | "busy" | "offline" | "away";
};
updatedAt: number; updatedAt: number;
userId?: null | string; userId?: null | string;
username?: null | string; username?: null | string;
}; };
model: "user"; 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<string>;
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: { data: {
createdAt: number; createdAt: number;
@ -132,8 +187,176 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
| "username" | "username"
| "displayUsername" | "displayUsername"
| "metadata" | "metadata"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "userStatus";
where?: Array<{
connector?: "AND" | "OR";
field:
| "userId"
| "status" | "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<string>
| Array<number>
| 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<string>
| Array<number>
| 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<string>
| Array<number>
| 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<string>
| Array<number>
| null;
}>;
}
| {
model: "attachments";
where?: Array<{
connector?: "AND" | "OR";
field:
| "contentType"
| "description"
| "ephemeral"
| "height"
| "width"
| "id"
| "size"
| "spoiler"
| "url"
| "_id"; | "_id";
operator?: operator?:
| "lt" | "lt"
@ -346,8 +569,176 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
| "username" | "username"
| "displayUsername" | "displayUsername"
| "metadata" | "metadata"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "userStatus";
where?: Array<{
connector?: "AND" | "OR";
field:
| "userId"
| "status" | "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<string>
| Array<number>
| 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<string>
| Array<number>
| 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<string>
| Array<number>
| 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<string>
| Array<number>
| null;
}>;
}
| {
model: "attachments";
where?: Array<{
connector?: "AND" | "OR";
field:
| "contentType"
| "description"
| "ephemeral"
| "height"
| "width"
| "id"
| "size"
| "spoiler"
| "url"
| "_id"; | "_id";
operator?: operator?:
| "lt" | "lt"
@ -539,6 +930,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
limit?: number; limit?: number;
model: model:
| "user" | "user"
| "userStatus"
| "friendRequests"
| "friends"
| "messages"
| "attachments"
| "session" | "session"
| "account" | "account"
| "verification" | "verification"
@ -587,6 +983,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
{ {
model: model:
| "user" | "user"
| "userStatus"
| "friendRequests"
| "friends"
| "messages"
| "attachments"
| "session" | "session"
| "account" | "account"
| "verification" | "verification"
@ -632,16 +1033,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
displayUsername?: null | string; displayUsername?: null | string;
email?: string; email?: string;
emailVerified?: boolean; emailVerified?: boolean;
friends?: Array<string>;
image?: null | string; image?: null | string;
metadata?: { metadata?: {
phrasePreference: "comforting" | "mocking" | "both"; phrasePreference: "comforting" | "mocking" | "both";
}; };
name?: string; name?: string;
status?: {
isUserSet: boolean;
status: "online" | "busy" | "offline" | "away";
};
updatedAt?: number; updatedAt?: number;
userId?: null | string; userId?: null | string;
username?: null | string; username?: null | string;
@ -659,8 +1055,225 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
| "username" | "username"
| "displayUsername" | "displayUsername"
| "metadata" | "metadata"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "userStatus";
update: {
isUserSet?: boolean;
status?: "online" | "busy" | "offline" | "away";
updatedAt?: number;
userId?: string;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "userId"
| "status" | "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<string>
| Array<number>
| 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<string>
| Array<number>
| 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<string>
| Array<number>
| null;
}>;
}
| {
model: "messages";
update: {
attachments?: Array<string>;
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<string>
| Array<number>
| 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"; | "_id";
operator?: operator?:
| "lt" | "lt"
@ -905,16 +1518,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
displayUsername?: null | string; displayUsername?: null | string;
email?: string; email?: string;
emailVerified?: boolean; emailVerified?: boolean;
friends?: Array<string>;
image?: null | string; image?: null | string;
metadata?: { metadata?: {
phrasePreference: "comforting" | "mocking" | "both"; phrasePreference: "comforting" | "mocking" | "both";
}; };
name?: string; name?: string;
status?: {
isUserSet: boolean;
status: "online" | "busy" | "offline" | "away";
};
updatedAt?: number; updatedAt?: number;
userId?: null | string; userId?: null | string;
username?: null | string; username?: null | string;
@ -932,8 +1540,225 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
| "username" | "username"
| "displayUsername" | "displayUsername"
| "metadata" | "metadata"
| "_id";
operator?:
| "lt"
| "lte"
| "gt"
| "gte"
| "eq"
| "in"
| "not_in"
| "ne"
| "contains"
| "starts_with"
| "ends_with";
value:
| string
| number
| boolean
| Array<string>
| Array<number>
| null;
}>;
}
| {
model: "userStatus";
update: {
isUserSet?: boolean;
status?: "online" | "busy" | "offline" | "away";
updatedAt?: number;
userId?: string;
};
where?: Array<{
connector?: "AND" | "OR";
field:
| "userId"
| "status" | "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<string>
| Array<number>
| 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<string>
| Array<number>
| 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<string>
| Array<number>
| null;
}>;
}
| {
model: "messages";
update: {
attachments?: Array<string>;
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<string>
| Array<number>
| 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"; | "_id";
operator?: operator?:
| "lt" | "lt"
@ -1184,10 +2009,43 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
}; };
user: { user: {
index: { 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< updateUserStatus: FunctionReference<
"mutation", "mutation",
"internal", "internal",
{ isUserSet: boolean; status: string }, {
isUserSet: boolean;
status: "online" | "busy" | "offline" | "away";
},
any, any,
Name Name
>; >;

View file

@ -1,6 +1,6 @@
import { v } from "convex/values"; import { v } from "convex/values";
import { Id } from "../../_generated/dataModel"; import { Id } from "../../_generated/dataModel";
import { mutation, query } from "../../_generated/server"; import { mutation, query } from "../_generated/server";
export const sendKeysToServer = mutation({ export const sendKeysToServer = mutation({
args: { args: {
@ -16,11 +16,10 @@ export const sendKeysToServer = mutation({
forceInsert: v.boolean(), // if true, insert even if user already has an olm account forceInsert: v.boolean(), // if true, insert even if user already has an olm account
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
console.log("sendKeysToServer", args);
// check if user already has an olm account // 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(); const olmAccount = await ctx.db.query("olmAccount").withIndex("userId", (q) => q.eq("userId", args.userId)).first();
console.log("olmAccount", olmAccount);
if (olmAccount && !args.forceInsert) { if (olmAccount && !args.forceInsert) {
throw new Error("User already has an olm account"); throw new Error("User already has an olm account");
} }
@ -42,9 +41,8 @@ export const retrieveServerOlmAccount = query({
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const olmAccount = await ctx.db.get<"olmAccount">(args.userId as Id<"olmAccount">); const olmAccount = await ctx.db.get<"olmAccount">(args.userId as Id<"olmAccount">);
if (olmAccount) { if (olmAccount) return olmAccount;
return olmAccount;
}
return null; return null;
}, },
}); });

View file

@ -4,33 +4,43 @@
import { defineSchema, defineTable } from "convex/server"; import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values"; 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 = { export const tables = {
user: defineTable({ ...user,
name: v.string(), messages: defineTable(Message),
email: v.string(), attachments: defineTable(Attachment),
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"]),
session: defineTable({ session: defineTable({
expiresAt: v.number(), expiresAt: v.number(),
token: v.string(), token: v.string(),
@ -87,7 +97,9 @@ export const tables = {
publicKey: v.string(), publicKey: v.string(),
})), })),
}) })
.index("userId", ["userId"]), .index("userId", ["userId"])
.index("userId_keys", ["userId", "oneTimeKeys"])
.index("userId_identityKey", ["userId", "identityKey"]),
}; };
const schema = defineSchema(tables); const schema = defineSchema(tables);

View file

@ -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"]),
}

View file

@ -1,28 +1,320 @@
import { v } from "convex/values"; import { v } from "convex/values";
import { Id } from "../../_generated/dataModel"; import { Id } from "../_generated/dataModel";
import { mutation } from "../../_generated/server"; 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({ export const updateUserStatus = mutation({
args: { args: {
status: v.string(), status: v.union(v.literal("online"), v.literal("busy"), v.literal("offline"), v.literal("away")),
isUserSet: v.boolean(), isUserSet: v.boolean(),
}, },
handler: async (ctx, args) => { handler: async (ctx, args) => {
const user = await ctx.auth.getUserIdentity(); try {
if (!user) { const { userId } = await userValidation(ctx);
throw new Error("User not found");
}
const userId = ctx.db.normalizeId("user", user.subject as string) as Id<"user">; // Check if user status is already set
if (!userId) { const userStatus = await ctx.db.query("userStatus").withIndex("userId", (q) => q.eq("userId", userId)).first();
throw new Error("User not found"); 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, { export const getUserStatus = query({
status: { handler: async (ctx) => {
status: args.status, const { userId } = await userValidation(ctx);
isUserSet: args.isUserSet, 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,
}); });
}, },
}); });
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);
}
})

View file

@ -10,6 +10,7 @@
"start:server": "NODE_ENV=development tsx src/server.ts" "start:server": "NODE_ENV=development tsx src/server.ts"
}, },
"dependencies": { "dependencies": {
"@better-fetch/fetch": "^1.1.21",
"@convex-dev/better-auth": "^0.10.4", "@convex-dev/better-auth": "^0.10.4",
"@marsidev/react-turnstile": "^1.4.0", "@marsidev/react-turnstile": "^1.4.0",
"@matrix-org/olm": "^3.2.15", "@matrix-org/olm": "^3.2.15",

View file

@ -1,5 +1,6 @@
"use client" "use client"
import AppSidebar from "@/components/home"; import AppSidebar from "@/components/home";
import FriendRequestModal from "@/components/home/modals/friendRequest";
import OlmSetupDialog from "@/components/olm/olm-setup-dialog"; import OlmSetupDialog from "@/components/olm/olm-setup-dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
@ -9,11 +10,10 @@ import { useOlmSetup } from "@/hooks/use-olm-setup";
import { useSocket } from "@/hooks/use-socket"; import { useSocket } from "@/hooks/use-socket";
import { authClient } from "@/lib/auth/client"; import { authClient } from "@/lib/auth/client";
import { useMutation, useQuery } from "convex/react"; 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 { redirect } from "next/navigation";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { api } from "../../convex/_generated/api"; import { api } from "../../convex/_generated/api";
const mockPhrases = [ const mockPhrases = [
"No bitches? Womp womp", "No bitches? Womp womp",
"You're all alone", "You're all alone",
@ -94,9 +94,9 @@ const comfortingPhrases = [
]; ];
export default function Home() { 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<SiPher.Channel | null>(null); const [currentChannel, setCurrentChannel] = useState<SiPher.Channel | null>(null);
const [openDmChannels, setOpenDmChannels] = useState<SiPher.Channel[] | []>([]); const [openDmChannels, setOpenDmChannels] = useState<SiPher.Channel[] | []>([]);
const [availableServers, setAvailableServers] = useState<SiPher.Server[] | []>([]); const [availableServers, setAvailableServers] = useState<SiPher.Server[] | []>([]);
@ -104,29 +104,55 @@ export default function Home() {
// Friends page state // Friends page state
const [friendsPage, setFriendsPage] = useState<"all" | "available">("all"); const [friendsPage, setFriendsPage] = useState<"all" | "available">("all");
const [friendsSearch, setFriendsSearch] = useState<string>(""); const [friendsSearch, setFriendsSearch] = useState<string>("");
const [friendModal, setFriendModal] = useState<boolean>(false);
const hasServerOlm = useQuery( const hasServerOlm = useQuery(
api.auth.retrieveServerOlmAccount, api.auth.retrieveServerOlmAccount,
data?.user?.id ? { userId: data.user.id } : "skip" 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<typeof friends>[number];
// Mutation for sending keys to server // Mutation for sending keys to server
const sendKeysToServer = useMutation(api.auth.sendKeysToServer); const sendKeysToServer = useMutation(api.auth.sendKeysToServer);
const updateUserStatus = useMutation(api.auth.updateUserStatus);
const updateUserMetadata = useMutation(api.auth.updateUserMetadata);
useEffect(() => { useEffect(() => {
if (!data) return; if (!data) return;
const status = data.user.status const metadata = data.user.metadata
if (!status) return; if (!metadata) {
console.debug(
if (status.status === "offline" && !status.isUserSet) { "[Home] > User metadata set",
updateUserStatus({ status: "online", isUserSet: false }); data.user.metadata
)
updateUserMetadata({ metadata: { phrasePreference: "comforting" } });
return
} }
}, [data?.user?.id, updateUserStatus, data?.user?.status]); }, [data, updateUserMetadata]);
// Custom hooks for socket and OLM management // 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({ const { olmStatus, showOlmModal, setShowOlmModal, handleCreateAccount } = useOlmSetup({
userId: data?.user?.id, userId: data?.user?.id,
hasServerOlm, hasServerOlm,
@ -140,10 +166,9 @@ export default function Home() {
} }
if (error || !data) { 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 getRandomPhrase = useCallback(() => {
const phrases = { const phrases = {
comforting: comfortingPhrases, comforting: comfortingPhrases,
@ -161,7 +186,7 @@ export default function Home() {
return ( return (
<> <>
<UserFloatingCard user={data.user} /> <UserFloatingCard user={data.user} />
<AppSidebar socketStatus={socketStatus} socketInfo={socketInfo}> <AppSidebar socketStatus={socketStatus} socketInfo={socketInfo} disconnectSocket={disconnect} connectSocket={connect}>
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Header - fixed height and sticky */} {/* Header - fixed height and sticky */}
<div className="flex items-center min-h-10 max-h-10 border-b border-border/40 sticky top-0 z-10 bg-background"> <div className="flex items-center min-h-10 max-h-10 border-b border-border/40 sticky top-0 z-10 bg-background">
@ -183,24 +208,28 @@ export default function Home() {
} }
</div> </div>
{/* Page title/options */} {/* Page title/options */}
<div className="flex flex-row justify-start items-center gap-2 w-full"> {
<div className="flex flex-row gap-2 justify-start p-2"> page === "friends" ? (
<UsersIcon className="size-4" /> <div className="flex flex-row justify-start items-center gap-2 w-full">
<span className="text-sm font-medium">Friends</span> <div className="flex flex-row gap-2 justify-start p-2">
</div> <UsersIcon className="size-4" />
<span className="text-sm font-medium"></span> <span className="text-sm font-medium">Friends</span>
<div className="flex flex-row gap-2 h-full"> </div>
<Button variant="ghost" disabled={friendsPage === "available"} className={`h-full hover:cursor-pointer justify-start p-2 ${friendsPage === "available" ? "bg-primary text-primary-foreground" : ""}`} onClick={() => setFriendsPage("available")}> <span className="text-sm font-medium"></span>
Available <div className="flex flex-row gap-2 h-full">
</Button> <Button variant="ghost" disabled={friendsPage === "available"} className={`h-full hover:cursor-pointer justify-start p-2 ${friendsPage === "available" ? "bg-primary text-primary-foreground" : ""}`} onClick={() => setFriendsPage("available")}>
<Button variant="ghost" disabled={friendsPage === "all"} className={`h-full hover:cursor-pointer justify-start p-2 ${friendsPage === "all" ? "bg-primary text-primary-foreground" : ""}`} onClick={() => setFriendsPage("all")}> Available
All Known </Button>
</Button> <Button variant="ghost" disabled={friendsPage === "all"} className={`h-full hover:cursor-pointer justify-start p-2 ${friendsPage === "all" ? "bg-primary text-primary-foreground" : ""}`} onClick={() => setFriendsPage("all")}>
<Button variant="ghost" className="h-full bg-primary text-primary-foreground hover:cursor-pointer justify-start p-2 "> All Known
Add Friend </Button>
</Button> <Button variant="ghost" className="h-full bg-primary text-primary-foreground hover:cursor-pointer justify-start p-2" onClick={() => setFriendModal(true)}>
</div> Add Friend
</div> </Button>
</div>
</div>
) : null
}
</div> </div>
{/* Content Area - Channel List + Main Content */} {/* Content Area - Channel List + Main Content */}
<div className="flex flex-1 overflow-hidden"> <div className="flex flex-1 overflow-hidden">
@ -213,6 +242,10 @@ export default function Home() {
<UsersIcon className="size-4" /> <UsersIcon className="size-4" />
<span className="text-sm font-medium">Friends</span> <span className="text-sm font-medium">Friends</span>
</Button> </Button>
<Button variant="ghost" className="w-full h-full hover:cursor-pointer justify-start" onClick={() => setPage("support")}>
<SettingsIcon className="size-4" />
<span className="text-sm font-medium">Settings</span>
</Button>
</div> </div>
</div> </div>
<div className="w-[calc(100%-0.8rem)] h-px bg-border/40 mx-2" /> <div className="w-[calc(100%-0.8rem)] h-px bg-border/40 mx-2" />
@ -269,19 +302,42 @@ export default function Home() {
/> />
{ {
friendsPage === "all" ? ( friendsPage === "all" ? (
<div className="flex items-center min-h-10 max-h-10"> <div className="flex flex-col items-start w-full p-2 gap-2 pt-4">
<span className="text-sm font-medium">All Friends</span> <span className="text-sm text-start font-medium">All Friends {friends ? friends.length : 0}</span>
{
friends && friends.length > 0 ? (
friends.map((friend: Friend) => {
if (!friend) return null;
return (
<div key={friend._id} className="flex items-center min-h-10 max-h-10">
<span className="text-sm font-medium">{friend.displayUsername || friend.username || friend.name}</span>
</div>
)
})
) : (
<span className="text-sm font-medium text-muted-foreground">
{getRandomPhrase()}
</span>
)
}
</div> </div>
) : ( ) : (
<div className="flex flex-col items-start w-full p-2 gap-2 pt-4"> <div className="flex flex-col items-start w-full p-2 gap-2 pt-4">
<span className="text-sm text-start font-medium">Available Friends {data.user.friends && data.user.friends.length > 0 ? data.user.friends.length : 0}</span> <span className="text-sm text-start font-medium">Available Friends {friends ? friends.filter((f: Friend) => f && f.status?.status !== "offline").length : 0}</span>
{ {
data.user.friends && data.user.friends.length > 0 ? ( friends && friends.length > 0 ? (
data.user.friends.map((friend) => ( friends
<div className="flex items-center min-h-10 max-h-10"> .filter((f: Friend) => f && f.status?.status !== "offline")
<span className="text-sm font-medium">{friend}</span> .map((friend: Friend) => {
</div> if (!friend) return null;
))
return (
<div key={friend._id} className="flex items-center min-h-10 max-h-10">
<span className="text-sm font-medium">{friend.displayUsername || friend.username || friend.name}</span>
</div>
)
})
) : ( ) : (
<span className="text-sm font-medium text-muted-foreground"> <span className="text-sm font-medium text-muted-foreground">
{getRandomPhrase()} {getRandomPhrase()}
@ -293,7 +349,7 @@ export default function Home() {
} }
</div> </div>
</div> </div>
) : page === "settings" ? ( ) : page === "support" ? (
<div className="flex flex-col flex-1 overflow-y-auto p-4"> <div className="flex flex-col flex-1 overflow-y-auto p-4">
<div className="flex items-center min-h-10 max-h-10"> <div className="flex items-center min-h-10 max-h-10">
<span className="text-sm font-medium">Servers</span> <span className="text-sm font-medium">Servers</span>
@ -306,6 +362,10 @@ export default function Home() {
</div> </div>
</AppSidebar> </AppSidebar>
<FriendRequestModal
open={friendModal}
onOpenChange={setFriendModal}
/>
{/* OLM Account Setup/Sync Modal */} {/* OLM Account Setup/Sync Modal */}
<OlmSetupDialog <OlmSetupDialog
open={showOlmModal} open={showOlmModal}

View file

@ -2,8 +2,9 @@
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { BroadcastIcon as Broadcast } from "@phosphor-icons/react"; import { BroadcastIcon as Broadcast } from "@phosphor-icons/react";
import { Activity, Clock, Globe, Radio, Zap } from "lucide-react"; import { Activity, Clock, Globe, LogInIcon, LogOutIcon, Radio, Zap } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button } from "../ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover"; import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
function formatUptime(ms: number): string { function formatUptime(ms: number): string {
@ -19,7 +20,7 @@ function formatUptime(ms: number): string {
/** /**
* Connection status indicator with popover details * Connection status indicator with popover details
*/ */
export default function ConnectionStatusIndicator({ socketStatus, socketInfo }: { socketStatus: SiPher.SocketStatus; socketInfo: SiPher.SocketInfo }) { export default function ConnectionStatusIndicator({ socketStatus, socketInfo, disconnectSocket, connectSocket }: { socketStatus: SiPher.SocketStatus; socketInfo: SiPher.SocketInfo; disconnectSocket: () => void; connectSocket: () => void }) {
const [uptime, setUptime] = useState<string>("0s"); const [uptime, setUptime] = useState<string>("0s");
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
@ -188,10 +189,21 @@ export default function ConnectionStatusIndicator({ socketStatus, socketInfo }:
</div> </div>
{/* Footer hint */} {/* Footer hint */}
<div className="px-4 py-2 border-t border-border bg-muted/30"> <div className="flex flex-row items-center justify-between gap-2 px-4 py-2 border-t border-border bg-muted/30">
<p className="text-[10px] text-muted-foreground text-center"> <p className="text-[10px] text-muted-foreground text-center">
Real-time connection via Socket.IO Real-time connection via Socket.IO
</p> </p>
<Button variant="ghost" size="icon-sm" className="hover:cursor-pointer hover:bg-transparent!" onClick={() => {
socketStatus === "connected" ? disconnectSocket() : connectSocket();
}}>
{
socketStatus === "connected" ? (
<LogOutIcon className="size-4" />
) : (
<LogInIcon className="size-4" />
)
}
</Button>
</div> </div>
</PopoverContent> </PopoverContent>
</Popover> </Popover>

View file

@ -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. * 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 * @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<string>("home"); const [activeItem, setActiveItem] = useState<string>("home");
return ( return (
@ -120,7 +120,7 @@ export default function AppSidebar({ children, socketStatus, socketInfo, current
} }
</div> </div>
{/* Socket connection status */} {/* Socket connection status */}
<ConnectionStatusIndicator socketStatus={socketStatus} socketInfo={socketInfo} /> <ConnectionStatusIndicator socketStatus={socketStatus} socketInfo={socketInfo} disconnectSocket={disconnectSocket} connectSocket={connectSocket} />
</div> </div>
<div className="w-9 md:hidden" /> {/* Spacer for centering on mobile */} <div className="w-9 md:hidden" /> {/* Spacer for centering on mobile */}
</header> </header>

View file

@ -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<string | null>(null);
const [activeTab, setActiveTab] = useState<"send" | "pending" | "sent">("send");
const [pendingRequests, setPendingRequests] = useState<any[]>([]);
const [sentRequests, setSentRequests] = useState<any[]>([]);
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<HTMLInputElement>) => {
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<UserPlusIcon className="size-5" />
Friend Requests
</DialogTitle>
<DialogDescription>
Send, accept, or manage your friend requests.
</DialogDescription>
</DialogHeader>
{/* Tabs */}
<div className="flex items-center gap-2 border-b border-border">
<Button
variant="ghost"
size="sm"
className={`rounded-b-none ${activeTab === "send" ? "border-b-2 border-primary" : ""}`}
onClick={() => setActiveTab("send")}
>
Send Request
</Button>
<Button
variant="ghost"
size="sm"
className={`rounded-b-none ${activeTab === "pending" ? "border-b-2 border-primary" : ""}`}
onClick={() => setActiveTab("pending")}
>
Pending ({pendingRequests.length})
</Button>
<Button
variant="ghost"
size="sm"
className={`rounded-b-none ${activeTab === "sent" ? "border-b-2 border-primary" : ""}`}
onClick={() => setActiveTab("sent")}
>
Sent ({sentRequests.length})
</Button>
</div>
{/* Content */}
<div className="min-h-[200px] max-h-[400px] overflow-y-auto">
{activeTab === "send" && (
<div className="flex flex-col gap-4 py-2">
<div className="flex flex-col gap-2">
<Input
placeholder="Enter username..."
value={username}
onChange={(e) => setUsername(e.target.value)}
onKeyDown={handleKeyDown}
disabled={isLoading}
/>
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
</div>
<Button
onClick={handleSendRequest}
disabled={!username.trim() || isLoading}
className="w-full"
>
{isLoading ? (
<>
<Spinner className="size-4 animate-spin mr-2" />
Sending...
</>
) : (
<>
<UserPlusIcon className="size-4 mr-2" />
Send Friend Request
</>
)}
</Button>
</div>
)}
{activeTab === "pending" && (
<div className="flex flex-col gap-2 py-2">
{pendingRequests.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<p className="text-sm text-muted-foreground">
No pending friend requests
</p>
</div>
) : (
pendingRequests.map((request) => (
<div
key={request.id}
className="flex items-center justify-between gap-3 p-3 rounded-lg border border-border hover:bg-accent/50 transition-colors"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<Avatar className="size-10 shrink-0">
<AvatarImage src={request.avatar} alt={request.username} />
<AvatarFallback className="bg-primary/20 text-primary-foreground font-semibold">
{request.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm font-medium truncate">
{request.username}
</span>
<span className="text-xs text-muted-foreground">
{formatTimeAgo(request.createdAt)}
</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button
size="icon-sm"
variant="ghost"
className="size-8 text-green-500 hover:text-green-600 hover:bg-green-500/10"
onClick={() => handleAccept(request.id)}
disabled={isLoading}
>
<CheckIcon className="size-4" />
</Button>
<Button
size="icon-sm"
variant="ghost"
className="size-8 text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleDecline(request.id)}
disabled={isLoading}
>
<XIcon className="size-4" />
</Button>
</div>
</div>
))
)}
</div>
)}
{activeTab === "sent" && (
<div className="flex flex-col gap-2 py-2">
{sentRequests.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<p className="text-sm text-muted-foreground">
No sent friend requests
</p>
</div>
) : (
sentRequests.map((request) => (
<div
key={request.id}
className="flex items-center justify-between gap-3 p-3 rounded-lg border border-border"
>
<div className="flex items-center gap-3 flex-1 min-w-0">
<Avatar className="size-10 shrink-0">
<AvatarImage src={request.avatar} alt={request.username} />
<AvatarFallback className="bg-primary/20 text-primary-foreground font-semibold">
{request.username.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex flex-col min-w-0 flex-1">
<span className="text-sm font-medium truncate">
{request.username}
</span>
<span className="text-xs text-muted-foreground">
Sent {formatTimeAgo(request.createdAt)}
</span>
</div>
</div>
<span className="text-xs text-muted-foreground shrink-0">
Pending...
</span>
</div>
))
)}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -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<Socket | null>(null)
const [isConnected, setIsConnected] = useState(false)
const [messages, setMessages] = useState<string[]>([])
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 (
<Card className="w-full max-w-2xl">
<CardHeader>
<CardTitle>Socket.IO Test Client</CardTitle>
<CardDescription>
Status: {isConnected ? (
<span className="text-green-600 font-semibold">🟢 Connected</span>
) : (
<span className="text-red-600 font-semibold">🔴 Disconnected</span>
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Input
type="text"
placeholder="Enter message..."
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && sendMessage()}
disabled={!isConnected}
/>
<Button onClick={sendMessage} disabled={!isConnected}>
Send
</Button>
</div>
<div className="border rounded-lg p-4 h-64 overflow-y-auto bg-muted/20">
<div className="space-y-1 font-mono text-sm">
{messages.length === 0 ? (
<p className="text-muted-foreground">No messages yet...</p>
) : (
messages.map((msg, idx) => (
<p key={idx} className="text-xs">{msg}</p>
))
)}
</div>
</div>
</CardContent>
</Card>
)
}

View file

@ -6,7 +6,9 @@ import {
GearSix, GearSix,
MicrophoneSlash MicrophoneSlash
} from "@phosphor-icons/react"; } from "@phosphor-icons/react";
import { useQuery } from "convex/react";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { api } from "../../../../convex/_generated/api";
import { Avatar, AvatarFallback, AvatarImage } from "../avatar"; import { Avatar, AvatarFallback, AvatarImage } from "../avatar";
import { Button } from "../button"; import { Button } from "../button";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "../hover-card"; import { HoverCard, HoverCardContent, HoverCardTrigger } from "../hover-card";
@ -22,19 +24,21 @@ interface UserFloatingCardProps {
const statusColors: Record<UserStatus, string> = { const statusColors: Record<UserStatus, string> = {
online: "bg-emerald-500", online: "bg-emerald-500",
busy: "bg-amber-500", busy: "bg-red-500",
away: "bg-yellow-500", away: "bg-yellow-500",
offline: "bg-muted-foreground" offline: "bg-muted-foreground"
}; };
export default function UserFloatingCard({ export default function UserFloatingCard(
user, { user }: UserFloatingCardProps
}: UserFloatingCardProps) { ) {
const [cardOpen, setCardOpen] = useState(false); const [cardOpen, setCardOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement | null>(null); const triggerRef = useRef<HTMLButtonElement | null>(null);
const contentRef = useRef<HTMLDivElement | null>(null); const contentRef = useRef<HTMLDivElement | null>(null);
const status = user.status?.status; const status = useQuery(api.auth.getUserStatus) as {
const activity = user.status?.activity; status: "online" | "busy" | "offline" | "away";
isUserSet: boolean;
} | null;
// Close when clicking outside the trigger/content // Close when clicking outside the trigger/content
useEffect(() => { useEffect(() => {
@ -113,7 +117,7 @@ export default function UserFloatingCard({
<span <span
className={cn( className={cn(
"absolute -bottom-0.5 -right-0.5 size-3.5 rounded-full border-[3px] border-secondary", "absolute -bottom-0.5 -right-0.5 size-3.5 rounded-full border-[3px] border-secondary",
status ? statusColors[status as UserStatus] : "bg-muted-foreground" status ? statusColors[status.status as UserStatus] : "bg-muted-foreground"
)} )}
/> />
</div> </div>
@ -124,19 +128,12 @@ export default function UserFloatingCard({
{user.name} {user.name}
</span> </span>
</div> </div>
{activity ? (
<div className="flex items-center gap-1 text-sm text-muted-foreground truncate"> <div className="flex items-center gap-1 text-xs text-muted-foreground/80 truncate italic">
<span className="text-[14px] leading-none">{"\u2022"}</span> <span className="text-[14px] leading-none">{"\u2022"}</span>
<span className="inline-flex items-center gap-1 text-[13px]"> <span>Activity status (coming soon)</span>
<span className="text-foreground/80">{activity}</span> </div>
</span>
</div>
) : (
<div className="flex items-center gap-1 text-xs text-muted-foreground/80 truncate italic">
<span className="text-[14px] leading-none">{"\u2022"}</span>
<span>Activity status (coming soon)</span>
</div>
)}
</div> </div>
</Button> </Button>
</HoverCardTrigger> </HoverCardTrigger>
@ -156,10 +153,8 @@ export default function UserFloatingCard({
</Avatar> </Avatar>
<div className="flex flex-col min-w-0"> <div className="flex flex-col min-w-0">
<span className="text-sm font-semibold text-foreground truncate">{user.name}</span> <span className="text-sm font-semibold text-foreground truncate">{user.name}</span>
<span className="text-xs text-muted-foreground truncate capitalize">{status}</span> <span className="text-xs text-muted-foreground truncate capitalize">{status?.status}</span>
<span className="text-xs text-muted-foreground truncate">
{activity ?? "Activity status (coming soon)"}
</span>
</div> </div>
</div> </div>
</HoverCardContent> </HoverCardContent>

View file

@ -1,9 +1,26 @@
"use client" "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 { 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<Socket | null>(null);
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
export function useSocket(userId: string | undefined) {
const [socketStatus, setSocketStatus] = useState<SiPher.SocketStatus>("connecting"); const [socketStatus, setSocketStatus] = useState<SiPher.SocketStatus>("connecting");
const [socketInfo, setSocketInfo] = useState<SiPher.SocketInfo>({ const [socketInfo, setSocketInfo] = useState<SiPher.SocketInfo>({
ping: null, ping: null,
@ -14,21 +31,81 @@ export function useSocket(userId: string | undefined) {
error: null 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(() => { useEffect(() => {
if (!userId) return; if (!user.id) return;
const socket: Socket = io({ withCredentials: false }); 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 measurePing = () => {
const start = Date.now(); const clientTimestamp = Date.now();
socket.volatile.emit("ping", () => {
const latency = Date.now() - start; // 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 })); 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", () => { socket.on("connect", () => {
console.log("✅ Connected to socket - Authentication successful!"); console.log("✅ Connected to socket - Authentication successful!");
setSocketStatus("connected"); setSocketStatus("connected");
@ -41,9 +118,11 @@ export function useSocket(userId: string | undefined) {
error: null error: null
})); }));
// Start ping measurement every 5 seconds setUserDefaultStatus("online", user.status);
// Start ping measurement every 5 seconds for latency display
measurePing(); measurePing();
pingInterval = setInterval(measurePing, 5000); pingIntervalRef.current = setInterval(measurePing, 5000);
}); });
// Update transport when it upgrades (polling -> websocket) // Update transport when it upgrades (polling -> websocket)
@ -53,6 +132,7 @@ export function useSocket(userId: string | undefined) {
socket.on("connect_error", (err) => { socket.on("connect_error", (err) => {
console.error("❌ Socket connection error:", err.message); console.error("❌ Socket connection error:", err.message);
setUserDefaultStatus("offline", user.status);
setSocketStatus("error"); setSocketStatus("error");
setSocketInfo((prev: SiPher.SocketInfo) => ({ setSocketInfo((prev: SiPher.SocketInfo) => ({
...prev, ...prev,
@ -65,6 +145,7 @@ export function useSocket(userId: string | undefined) {
socket.on("disconnect", (reason) => { socket.on("disconnect", (reason) => {
console.log("🔌 Disconnected from socket:", reason); console.log("🔌 Disconnected from socket:", reason);
setUserDefaultStatus("offline", user.status);
setSocketStatus("disconnected"); setSocketStatus("disconnected");
setSocketInfo((prev: SiPher.SocketInfo) => ({ setSocketInfo((prev: SiPher.SocketInfo) => ({
...prev, ...prev,
@ -72,7 +153,10 @@ export function useSocket(userId: string | undefined) {
connectedAt: null, connectedAt: null,
error: reason error: reason
})); }));
if (pingInterval) clearInterval(pingInterval); if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
}); });
// Handle pong response for ping measurement // Handle pong response for ping measurement
@ -81,11 +165,14 @@ export function useSocket(userId: string | undefined) {
}); });
return () => { return () => {
if (pingInterval) clearInterval(pingInterval); if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
}
socket.disconnect(); socket.disconnect();
}; };
}, [userId]); }, [user.id, updateUserStatus]);
return { socketStatus, socketInfo }; return { socketStatus, socketInfo, disconnect, connect };
} }

View file

@ -8,7 +8,7 @@ export const authClient = createAuthClient({
convexClient(), convexClient(),
usernameClient(), usernameClient(),
oneTimeTokenClient(), oneTimeTokenClient(),
inferAdditionalFields<typeof auth>() inferAdditionalFields<typeof auth>(),
], ],
sessionOptions: { sessionOptions: {
refetchOnWindowFocus: false, refetchOnWindowFocus: false,

View file

@ -26,7 +26,7 @@ interface DmMessage {
const dmEvent: SiPher.EventsType = { const dmEvent: SiPher.EventsType = {
name: "dm", 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", category: "user",
type: "message", type: "message",
handler: (socket: Socket, io: SocketIOServer, data: DmMessage) => { handler: (socket: Socket, io: SocketIOServer, data: DmMessage) => {

View file

@ -5,7 +5,7 @@ export default {
handler: (socket: Socket, io: SocketIOServer, ...args: any[]) => { handler: (socket: Socket, io: SocketIOServer, ...args: any[]) => {
console.log("Message received", args) console.log("Message received", args)
}, },
description: "A message event", description: "Send a message to a channel by using the server-side encryption",
category: "user", category: "server",
type: "message" type: "message"
} satisfies SiPher.EventsType } satisfies SiPher.EventsType

View file

@ -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;

View file

@ -2,6 +2,7 @@
* @fileoverview Socket Manager Class for handling socket connections and events at the server side. * @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 { existsSync, readdirSync } from "fs";
import type { Server as HTTPServer } from "http"; import type { Server as HTTPServer } from "http";
import path from "path"; import path from "path";
@ -41,7 +42,11 @@ export default class SocketManager {
}; };
if (!this.socketIo) { 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) { if (this.options.requireAuth) {
@ -54,7 +59,7 @@ export default class SocketManager {
this.socketIo.use(async (socket, next) => { this.socketIo.use(async (socket, next) => {
try { try {
let result: { user?: unknown; session?: unknown } | null = null; let result: { user?: User, session?: Session } | null = null;
if (this.options.authMethod === "ott") { if (this.options.authMethod === "ott") {
// OTT-based auth: client must provide token in auth object // 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")); 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 // 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 // Attach user and session to socket for use in event handlers
(socket as any).user = user; socket.user = user;
(socket as any).session = result.session; socket.session = session;
next(); next();
} catch (error) { } catch (error) {

View file

@ -1,3 +1,4 @@
import { Session, User } from "better-auth";
import { Socket, Server as SocketIOServer } from "socket.io"; import { Socket, Server as SocketIOServer } from "socket.io";
declare global { declare global {
@ -111,16 +112,18 @@ declare global {
id: string, id: string,
system: System system: System
} }
}
type MessageEvent = { // Add custom socket.io types
message: { }
/** Will either be a raw string or a encrypted blob, if it is a encrypted blob, the iv will be provided */
content: string, // Extend Socket.io types to include authenticated user data
iv?: string declare module "socket.io" {
}, interface Socket {
from: SipherUser, /** Authenticated user from Better Auth (set after auth middleware) */
recipient: MessageRecipient user?: User;
} /** Session data from Better Auth (set after auth middleware) */
session?: Session;
} }
} }

20
src/types/messages/encrypted.d.ts vendored Normal file
View file

@ -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 { }

24
src/types/messages/unencrypted.d.ts vendored Normal file
View file

@ -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<DBMessageType, "authorId" | "channelId" | "guildId"> & {
author: SipherUser,
channel: GuildChannel | RegionalChannel | GlobalChannel | DMChannel,
guild: Server | null,
attachments: Collection<string, DBAttachmentType>,
}
type ServerEncryptedMessageEvent = {
message: DBMessageType,
from: SipherUser,
recipient: MessageRecipient
}
}
}
export { };

View file

@ -6,6 +6,8 @@ declare global {
socketStatus: SocketStatus; socketStatus: SocketStatus;
socketInfo: SocketInfo; socketInfo: SocketInfo;
currentChannel?: SiPher.Channel; currentChannel?: SiPher.Channel;
disconnectSocket: () => void;
connectSocket: () => void;
} }
interface SidebarItem { interface SidebarItem {