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:
parent
5198a12f9e
commit
45301ac52b
26 changed files with 2860 additions and 332 deletions
1
bun.lock
1
bun.lock
|
|
@ -4,6 +4,7 @@
|
|||
"": {
|
||||
"name": "sipher",
|
||||
"dependencies": {
|
||||
"@better-fetch/fetch": "^1.1.21",
|
||||
"@convex-dev/better-auth": "^0.10.4",
|
||||
"@marsidev/react-turnstile": "^1.4.0",
|
||||
"@matrix-org/olm": "^3.2.15",
|
||||
|
|
|
|||
889
convex/_generated/api.d.ts
vendored
889
convex/_generated/api.d.ts
vendored
|
|
@ -62,22 +62,77 @@ export declare const components: {
|
|||
displayUsername?: null | string;
|
||||
email: string;
|
||||
emailVerified: boolean;
|
||||
friends?: Array<string>;
|
||||
image?: null | string;
|
||||
metadata?: {
|
||||
phrasePreference: "comforting" | "mocking" | "both";
|
||||
};
|
||||
name: string;
|
||||
status?: {
|
||||
isUserSet: boolean;
|
||||
status: "online" | "busy" | "offline" | "away";
|
||||
};
|
||||
updatedAt: number;
|
||||
userId?: null | string;
|
||||
username?: null | string;
|
||||
};
|
||||
model: "user";
|
||||
}
|
||||
| {
|
||||
data: {
|
||||
isUserSet: boolean;
|
||||
status: "online" | "busy" | "offline" | "away";
|
||||
updatedAt: number;
|
||||
userId: string;
|
||||
};
|
||||
model: "userStatus";
|
||||
}
|
||||
| {
|
||||
data: {
|
||||
acceptedAt?: number;
|
||||
createdAt: number;
|
||||
declinedAt?: number;
|
||||
expiresAt?: number;
|
||||
ignoredAt?: number;
|
||||
method: "receive" | "send";
|
||||
requestId: string;
|
||||
requestTo: string;
|
||||
userId: string;
|
||||
};
|
||||
model: "friendRequests";
|
||||
}
|
||||
| {
|
||||
data: { createdAt: number; friendId: string; userId: string };
|
||||
model: "friends";
|
||||
}
|
||||
| {
|
||||
data: {
|
||||
attachments?: Array<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: {
|
||||
createdAt: number;
|
||||
|
|
@ -158,8 +213,176 @@ export declare const components: {
|
|||
| "username"
|
||||
| "displayUsername"
|
||||
| "metadata"
|
||||
| "_id";
|
||||
operator?:
|
||||
| "lt"
|
||||
| "lte"
|
||||
| "gt"
|
||||
| "gte"
|
||||
| "eq"
|
||||
| "in"
|
||||
| "not_in"
|
||||
| "ne"
|
||||
| "contains"
|
||||
| "starts_with"
|
||||
| "ends_with";
|
||||
value:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| Array<string>
|
||||
| Array<number>
|
||||
| null;
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
model: "userStatus";
|
||||
where?: Array<{
|
||||
connector?: "AND" | "OR";
|
||||
field:
|
||||
| "userId"
|
||||
| "status"
|
||||
| "friends"
|
||||
| "isUserSet"
|
||||
| "updatedAt"
|
||||
| "_id";
|
||||
operator?:
|
||||
| "lt"
|
||||
| "lte"
|
||||
| "gt"
|
||||
| "gte"
|
||||
| "eq"
|
||||
| "in"
|
||||
| "not_in"
|
||||
| "ne"
|
||||
| "contains"
|
||||
| "starts_with"
|
||||
| "ends_with";
|
||||
value:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| Array<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";
|
||||
operator?:
|
||||
| "lt"
|
||||
|
|
@ -371,8 +594,176 @@ export declare const components: {
|
|||
| "username"
|
||||
| "displayUsername"
|
||||
| "metadata"
|
||||
| "_id";
|
||||
operator?:
|
||||
| "lt"
|
||||
| "lte"
|
||||
| "gt"
|
||||
| "gte"
|
||||
| "eq"
|
||||
| "in"
|
||||
| "not_in"
|
||||
| "ne"
|
||||
| "contains"
|
||||
| "starts_with"
|
||||
| "ends_with";
|
||||
value:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| Array<string>
|
||||
| Array<number>
|
||||
| null;
|
||||
}>;
|
||||
}
|
||||
| {
|
||||
model: "userStatus";
|
||||
where?: Array<{
|
||||
connector?: "AND" | "OR";
|
||||
field:
|
||||
| "userId"
|
||||
| "status"
|
||||
| "friends"
|
||||
| "isUserSet"
|
||||
| "updatedAt"
|
||||
| "_id";
|
||||
operator?:
|
||||
| "lt"
|
||||
| "lte"
|
||||
| "gt"
|
||||
| "gte"
|
||||
| "eq"
|
||||
| "in"
|
||||
| "not_in"
|
||||
| "ne"
|
||||
| "contains"
|
||||
| "starts_with"
|
||||
| "ends_with";
|
||||
value:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| Array<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";
|
||||
operator?:
|
||||
| "lt"
|
||||
|
|
@ -563,6 +954,11 @@ export declare const components: {
|
|||
limit?: number;
|
||||
model:
|
||||
| "user"
|
||||
| "userStatus"
|
||||
| "friendRequests"
|
||||
| "friends"
|
||||
| "messages"
|
||||
| "attachments"
|
||||
| "session"
|
||||
| "account"
|
||||
| "verification"
|
||||
|
|
@ -610,6 +1006,11 @@ export declare const components: {
|
|||
{
|
||||
model:
|
||||
| "user"
|
||||
| "userStatus"
|
||||
| "friendRequests"
|
||||
| "friends"
|
||||
| "messages"
|
||||
| "attachments"
|
||||
| "session"
|
||||
| "account"
|
||||
| "verification"
|
||||
|
|
@ -654,16 +1055,11 @@ export declare const components: {
|
|||
displayUsername?: null | string;
|
||||
email?: string;
|
||||
emailVerified?: boolean;
|
||||
friends?: Array<string>;
|
||||
image?: null | string;
|
||||
metadata?: {
|
||||
phrasePreference: "comforting" | "mocking" | "both";
|
||||
};
|
||||
name?: string;
|
||||
status?: {
|
||||
isUserSet: boolean;
|
||||
status: "online" | "busy" | "offline" | "away";
|
||||
};
|
||||
updatedAt?: number;
|
||||
userId?: null | string;
|
||||
username?: null | string;
|
||||
|
|
@ -681,8 +1077,225 @@ export declare const components: {
|
|||
| "username"
|
||||
| "displayUsername"
|
||||
| "metadata"
|
||||
| "_id";
|
||||
operator?:
|
||||
| "lt"
|
||||
| "lte"
|
||||
| "gt"
|
||||
| "gte"
|
||||
| "eq"
|
||||
| "in"
|
||||
| "not_in"
|
||||
| "ne"
|
||||
| "contains"
|
||||
| "starts_with"
|
||||
| "ends_with";
|
||||
value:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| Array<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"
|
||||
| "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";
|
||||
operator?:
|
||||
| "lt"
|
||||
|
|
@ -926,16 +1539,11 @@ export declare const components: {
|
|||
displayUsername?: null | string;
|
||||
email?: string;
|
||||
emailVerified?: boolean;
|
||||
friends?: Array<string>;
|
||||
image?: null | string;
|
||||
metadata?: {
|
||||
phrasePreference: "comforting" | "mocking" | "both";
|
||||
};
|
||||
name?: string;
|
||||
status?: {
|
||||
isUserSet: boolean;
|
||||
status: "online" | "busy" | "offline" | "away";
|
||||
};
|
||||
updatedAt?: number;
|
||||
userId?: null | string;
|
||||
username?: null | string;
|
||||
|
|
@ -953,8 +1561,225 @@ export declare const components: {
|
|||
| "username"
|
||||
| "displayUsername"
|
||||
| "metadata"
|
||||
| "_id";
|
||||
operator?:
|
||||
| "lt"
|
||||
| "lte"
|
||||
| "gt"
|
||||
| "gte"
|
||||
| "eq"
|
||||
| "in"
|
||||
| "not_in"
|
||||
| "ne"
|
||||
| "contains"
|
||||
| "starts_with"
|
||||
| "ends_with";
|
||||
value:
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| Array<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"
|
||||
| "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";
|
||||
operator?:
|
||||
| "lt"
|
||||
|
|
@ -1202,10 +2027,34 @@ export declare const components: {
|
|||
};
|
||||
user: {
|
||||
index: {
|
||||
answerFriendRequest: FunctionReference<
|
||||
"mutation",
|
||||
"internal",
|
||||
{ answer: "accept" | "decline" | "ignore"; requestId: string },
|
||||
any
|
||||
>;
|
||||
getFriendRequests: FunctionReference<"query", "internal", any, any>;
|
||||
getFriends: FunctionReference<"query", "internal", any, any>;
|
||||
getUserStatus: FunctionReference<"query", "internal", any, any>;
|
||||
sendFriendRequest: FunctionReference<
|
||||
"mutation",
|
||||
"internal",
|
||||
{ username: string },
|
||||
any
|
||||
>;
|
||||
updateUserMetadata: FunctionReference<
|
||||
"mutation",
|
||||
"internal",
|
||||
{ metadata: { phrasePreference: "comforting" | "mocking" | "both" } },
|
||||
any
|
||||
>;
|
||||
updateUserStatus: FunctionReference<
|
||||
"mutation",
|
||||
"internal",
|
||||
{ isUserSet: boolean; status: string },
|
||||
{
|
||||
isUserSet: boolean;
|
||||
status: "online" | "busy" | "offline" | "away";
|
||||
},
|
||||
any
|
||||
>;
|
||||
};
|
||||
|
|
|
|||
105
convex/auth.ts
105
convex/auth.ts
|
|
@ -3,7 +3,6 @@ import { convex } from "@convex-dev/better-auth/plugins";
|
|||
import { betterAuth, type BetterAuthOptions } from "better-auth";
|
||||
import { captcha, oneTimeToken, openAPI, username } from "better-auth/plugins";
|
||||
import { v } from "convex/values";
|
||||
import { z } from "zod";
|
||||
import { components } from "./_generated/api";
|
||||
import { DataModel } from "./_generated/dataModel";
|
||||
import { mutation, query } from "./_generated/server";
|
||||
|
|
@ -23,15 +22,6 @@ export const authComponent = createClient<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>) => {
|
||||
return {
|
||||
baseURL: siteUrl,
|
||||
|
|
@ -45,45 +35,12 @@ export const createAuthOptions = (ctx: GenericCtx<DataModel>) => {
|
|||
additionalFields: {
|
||||
metadata: {
|
||||
type: "json",
|
||||
defaultValue: () => {
|
||||
const metadata = metadataSchema.parse({
|
||||
phrasePreference: "comforting",
|
||||
})
|
||||
|
||||
return metadata.phrasePreference;
|
||||
},
|
||||
required: false,
|
||||
},
|
||||
friends: {
|
||||
type: "string[]",
|
||||
defaultValue: [],
|
||||
required: false,
|
||||
index: true
|
||||
},
|
||||
status: {
|
||||
type: "json",
|
||||
defaultValue: () => {
|
||||
return {
|
||||
status: "offline",
|
||||
isUserSet: false,
|
||||
}
|
||||
},
|
||||
required: false,
|
||||
index: true,
|
||||
transform: {
|
||||
input: (status) => {
|
||||
return statusSchema.safeParse(status).success ? status : {
|
||||
status: "offline",
|
||||
isUserSet: false,
|
||||
};
|
||||
},
|
||||
output: (status) => {
|
||||
return statusSchema.safeParse(status).success ? status : {
|
||||
status: "offline",
|
||||
isUserSet: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
|
@ -103,7 +60,7 @@ export const createAuthOptions = (ctx: GenericCtx<DataModel>) => {
|
|||
}
|
||||
}),
|
||||
oneTimeToken(),
|
||||
openAPI()
|
||||
openAPI(),
|
||||
],
|
||||
} satisfies BetterAuthOptions;
|
||||
}
|
||||
|
|
@ -159,7 +116,7 @@ export const retrieveServerOlmAccount = query({
|
|||
|
||||
export const updateUserStatus = mutation({
|
||||
args: {
|
||||
status: v.string(),
|
||||
status: v.union(v.literal("online"), v.literal("busy"), v.literal("offline"), v.literal("away")),
|
||||
isUserSet: v.boolean(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
|
|
@ -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)
|
||||
},
|
||||
});
|
||||
|
|
@ -11,6 +11,7 @@
|
|||
import type * as adapter from "../adapter.js";
|
||||
import type * as auth from "../auth.js";
|
||||
import type * as olm_index from "../olm/index.js";
|
||||
import type * as schemas_user from "../schemas/user.js";
|
||||
import type * as user_index from "../user/index.js";
|
||||
|
||||
import type {
|
||||
|
|
@ -24,6 +25,7 @@ const fullApi: ApiFromModules<{
|
|||
adapter: typeof adapter;
|
||||
auth: typeof auth;
|
||||
"olm/index": typeof olm_index;
|
||||
"schemas/user": typeof schemas_user;
|
||||
"user/index": typeof user_index;
|
||||
}> = anyApi as any;
|
||||
|
||||
|
|
|
|||
|
|
@ -35,22 +35,77 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|||
displayUsername?: null | string;
|
||||
email: string;
|
||||
emailVerified: boolean;
|
||||
friends?: Array<string>;
|
||||
image?: null | string;
|
||||
metadata?: {
|
||||
phrasePreference: "comforting" | "mocking" | "both";
|
||||
};
|
||||
name: string;
|
||||
status?: {
|
||||
isUserSet: boolean;
|
||||
status: "online" | "busy" | "offline" | "away";
|
||||
};
|
||||
updatedAt: number;
|
||||
userId?: null | string;
|
||||
username?: null | string;
|
||||
};
|
||||
model: "user";
|
||||
}
|
||||
| {
|
||||
data: {
|
||||
isUserSet: boolean;
|
||||
status: "online" | "busy" | "offline" | "away";
|
||||
updatedAt: number;
|
||||
userId: string;
|
||||
};
|
||||
model: "userStatus";
|
||||
}
|
||||
| {
|
||||
data: {
|
||||
acceptedAt?: number;
|
||||
createdAt: number;
|
||||
declinedAt?: number;
|
||||
expiresAt?: number;
|
||||
ignoredAt?: number;
|
||||
method: "receive" | "send";
|
||||
requestId: string;
|
||||
requestTo: string;
|
||||
userId: string;
|
||||
};
|
||||
model: "friendRequests";
|
||||
}
|
||||
| {
|
||||
data: { createdAt: number; friendId: string; userId: string };
|
||||
model: "friends";
|
||||
}
|
||||
| {
|
||||
data: {
|
||||
attachments?: Array<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: {
|
||||
createdAt: number;
|
||||
|
|
@ -132,8 +187,176 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|||
| "username"
|
||||
| "displayUsername"
|
||||
| "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"
|
||||
| "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";
|
||||
operator?:
|
||||
| "lt"
|
||||
|
|
@ -346,8 +569,176 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|||
| "username"
|
||||
| "displayUsername"
|
||||
| "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"
|
||||
| "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";
|
||||
operator?:
|
||||
| "lt"
|
||||
|
|
@ -539,6 +930,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|||
limit?: number;
|
||||
model:
|
||||
| "user"
|
||||
| "userStatus"
|
||||
| "friendRequests"
|
||||
| "friends"
|
||||
| "messages"
|
||||
| "attachments"
|
||||
| "session"
|
||||
| "account"
|
||||
| "verification"
|
||||
|
|
@ -587,6 +983,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|||
{
|
||||
model:
|
||||
| "user"
|
||||
| "userStatus"
|
||||
| "friendRequests"
|
||||
| "friends"
|
||||
| "messages"
|
||||
| "attachments"
|
||||
| "session"
|
||||
| "account"
|
||||
| "verification"
|
||||
|
|
@ -632,16 +1033,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|||
displayUsername?: null | string;
|
||||
email?: string;
|
||||
emailVerified?: boolean;
|
||||
friends?: Array<string>;
|
||||
image?: null | string;
|
||||
metadata?: {
|
||||
phrasePreference: "comforting" | "mocking" | "both";
|
||||
};
|
||||
name?: string;
|
||||
status?: {
|
||||
isUserSet: boolean;
|
||||
status: "online" | "busy" | "offline" | "away";
|
||||
};
|
||||
updatedAt?: number;
|
||||
userId?: null | string;
|
||||
username?: null | string;
|
||||
|
|
@ -659,8 +1055,225 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|||
| "username"
|
||||
| "displayUsername"
|
||||
| "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"
|
||||
| "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";
|
||||
operator?:
|
||||
| "lt"
|
||||
|
|
@ -905,16 +1518,11 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|||
displayUsername?: null | string;
|
||||
email?: string;
|
||||
emailVerified?: boolean;
|
||||
friends?: Array<string>;
|
||||
image?: null | string;
|
||||
metadata?: {
|
||||
phrasePreference: "comforting" | "mocking" | "both";
|
||||
};
|
||||
name?: string;
|
||||
status?: {
|
||||
isUserSet: boolean;
|
||||
status: "online" | "busy" | "offline" | "away";
|
||||
};
|
||||
updatedAt?: number;
|
||||
userId?: null | string;
|
||||
username?: null | string;
|
||||
|
|
@ -932,8 +1540,225 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|||
| "username"
|
||||
| "displayUsername"
|
||||
| "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"
|
||||
| "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";
|
||||
operator?:
|
||||
| "lt"
|
||||
|
|
@ -1184,10 +2009,43 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
|
|||
};
|
||||
user: {
|
||||
index: {
|
||||
answerFriendRequest: FunctionReference<
|
||||
"mutation",
|
||||
"internal",
|
||||
{ answer: "accept" | "decline" | "ignore"; requestId: string },
|
||||
any,
|
||||
Name
|
||||
>;
|
||||
getFriendRequests: FunctionReference<
|
||||
"query",
|
||||
"internal",
|
||||
any,
|
||||
any,
|
||||
Name
|
||||
>;
|
||||
getFriends: FunctionReference<"query", "internal", any, any, Name>;
|
||||
getUserStatus: FunctionReference<"query", "internal", any, any, Name>;
|
||||
sendFriendRequest: FunctionReference<
|
||||
"mutation",
|
||||
"internal",
|
||||
{ username: string },
|
||||
any,
|
||||
Name
|
||||
>;
|
||||
updateUserMetadata: FunctionReference<
|
||||
"mutation",
|
||||
"internal",
|
||||
{ metadata: { phrasePreference: "comforting" | "mocking" | "both" } },
|
||||
any,
|
||||
Name
|
||||
>;
|
||||
updateUserStatus: FunctionReference<
|
||||
"mutation",
|
||||
"internal",
|
||||
{ isUserSet: boolean; status: string },
|
||||
{
|
||||
isUserSet: boolean;
|
||||
status: "online" | "busy" | "offline" | "away";
|
||||
},
|
||||
any,
|
||||
Name
|
||||
>;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { v } from "convex/values";
|
||||
import { Id } from "../../_generated/dataModel";
|
||||
import { mutation, query } from "../../_generated/server";
|
||||
import { mutation, query } from "../_generated/server";
|
||||
|
||||
export const sendKeysToServer = mutation({
|
||||
args: {
|
||||
|
|
@ -16,11 +16,10 @@ export const sendKeysToServer = mutation({
|
|||
forceInsert: v.boolean(), // if true, insert even if user already has an olm account
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
console.log("sendKeysToServer", args);
|
||||
|
||||
// check if user already has an olm account
|
||||
// @ts-ignore
|
||||
const olmAccount = await ctx.db.query("olmAccount").withIndex("userId", (q) => q.eq("userId", args.userId)).first();
|
||||
console.log("olmAccount", olmAccount);
|
||||
|
||||
if (olmAccount && !args.forceInsert) {
|
||||
throw new Error("User already has an olm account");
|
||||
}
|
||||
|
|
@ -42,9 +41,8 @@ export const retrieveServerOlmAccount = query({
|
|||
},
|
||||
handler: async (ctx, args) => {
|
||||
const olmAccount = await ctx.db.get<"olmAccount">(args.userId as Id<"olmAccount">);
|
||||
if (olmAccount) {
|
||||
return olmAccount;
|
||||
}
|
||||
if (olmAccount) return olmAccount;
|
||||
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
|
@ -4,33 +4,43 @@
|
|||
|
||||
import { defineSchema, defineTable } from "convex/server";
|
||||
import { v } from "convex/values";
|
||||
import { user } from "./schemas/user";
|
||||
|
||||
const Attachment = v.object({
|
||||
contentType: v.string(), // MIME type
|
||||
description: v.union(v.null(), v.string()), // Description
|
||||
ephemeral: v.boolean(), // Whether the attachment is ephemeral
|
||||
height: v.optional(v.number()), // Height in pixels
|
||||
width: v.optional(v.number()), // Width in pixels
|
||||
id: v.id("storage"), // Storage ID
|
||||
size: v.number(), // Size in bytes
|
||||
spoiler: v.boolean(), // Whether the attachment is a spoiler
|
||||
url: v.string(), // Public URL
|
||||
});
|
||||
|
||||
const Message = v.object({
|
||||
inGuild: v.optional(v.boolean()),
|
||||
attachments: v.optional(v.array(v.id("attachments"))),
|
||||
authorId: v.id("user"),
|
||||
channelId: v.id("channel"),
|
||||
content: v.string(),
|
||||
createdAt: v.string(),
|
||||
createdTimestamp: v.number(),
|
||||
editedAt: v.optional(v.string()),
|
||||
guildId: v.optional(v.id("guild")),
|
||||
id: v.string(),
|
||||
nonce: v.optional(v.string()),
|
||||
position: v.optional(v.number()),
|
||||
referencedMessage: v.optional(
|
||||
v.union(v.null(), v.id("messages"), v.id("channel"), v.id("guild")),
|
||||
),
|
||||
url: v.optional(v.string()),
|
||||
})
|
||||
|
||||
export const tables = {
|
||||
user: defineTable({
|
||||
name: v.string(),
|
||||
email: v.string(),
|
||||
emailVerified: v.boolean(),
|
||||
image: v.optional(v.union(v.null(), v.string())),
|
||||
createdAt: v.number(),
|
||||
updatedAt: v.number(),
|
||||
userId: v.optional(v.union(v.null(), v.string())),
|
||||
username: v.optional(v.union(v.null(), v.string())),
|
||||
displayUsername: v.optional(v.union(v.null(), v.string())),
|
||||
metadata: v.optional(v.object({
|
||||
phrasePreference: v.union(v.literal("comforting"), v.literal("mocking"), v.literal("both")),
|
||||
})),
|
||||
status: v.optional(v.object({
|
||||
status: v.union(v.literal("online"), v.literal("busy"), v.literal("offline"), v.literal("away")),
|
||||
isUserSet: v.boolean(),
|
||||
})),
|
||||
friends: v.optional(v.array(v.string())),
|
||||
})
|
||||
.index("email_name", ["email", "name"])
|
||||
.index("name", ["name"])
|
||||
.index("userId", ["userId"])
|
||||
.index("username", ["username"])
|
||||
.index("status", ["status"])
|
||||
.index("friends", ["friends"]),
|
||||
...user,
|
||||
messages: defineTable(Message),
|
||||
attachments: defineTable(Attachment),
|
||||
session: defineTable({
|
||||
expiresAt: v.number(),
|
||||
token: v.string(),
|
||||
|
|
@ -87,7 +97,9 @@ export const tables = {
|
|||
publicKey: v.string(),
|
||||
})),
|
||||
})
|
||||
.index("userId", ["userId"]),
|
||||
.index("userId", ["userId"])
|
||||
.index("userId_keys", ["userId", "oneTimeKeys"])
|
||||
.index("userId_identityKey", ["userId", "identityKey"]),
|
||||
};
|
||||
|
||||
const schema = defineSchema(tables);
|
||||
|
|
|
|||
55
convex/betterAuth/schemas/user.ts
Normal file
55
convex/betterAuth/schemas/user.ts
Normal 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"]),
|
||||
}
|
||||
|
|
@ -1,28 +1,320 @@
|
|||
import { v } from "convex/values";
|
||||
import { Id } from "../../_generated/dataModel";
|
||||
import { mutation } from "../../_generated/server";
|
||||
import { Id } from "../_generated/dataModel";
|
||||
import { mutation, MutationCtx, query, QueryCtx } from "../_generated/server";
|
||||
|
||||
async function userValidation(ctx: MutationCtx | QueryCtx) {
|
||||
const user = await ctx.auth.getUserIdentity();
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
const userId = ctx.db.normalizeId("user", user.subject as string) as Id<"user">;
|
||||
if (!userId) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
|
||||
return {
|
||||
userId,
|
||||
user,
|
||||
}
|
||||
}
|
||||
|
||||
export const updateUserStatus = mutation({
|
||||
args: {
|
||||
status: v.string(),
|
||||
status: v.union(v.literal("online"), v.literal("busy"), v.literal("offline"), v.literal("away")),
|
||||
isUserSet: v.boolean(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const user = await ctx.auth.getUserIdentity();
|
||||
if (!user) {
|
||||
throw new Error("User not found");
|
||||
}
|
||||
try {
|
||||
const { userId } = await userValidation(ctx);
|
||||
|
||||
const userId = ctx.db.normalizeId("user", user.subject as string) as Id<"user">;
|
||||
if (!userId) {
|
||||
throw new Error("User not found");
|
||||
// Check if user status is already set
|
||||
const userStatus = await ctx.db.query("userStatus").withIndex("userId", (q) => q.eq("userId", userId)).first();
|
||||
if (userStatus) {
|
||||
await ctx.db.patch(userStatus._id, {
|
||||
status: args.status,
|
||||
isUserSet: args.isUserSet,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
} else {
|
||||
await ctx.db.insert("userStatus", {
|
||||
userId: userId,
|
||||
status: args.status,
|
||||
isUserSet: false,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
return { success: true, message: "User status updated successfully" };
|
||||
} catch (error) {
|
||||
console.error("Error updating user status:", error);
|
||||
throw new Error("Failed to update user status");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return ctx.db.patch<"user">("user", userId, {
|
||||
status: {
|
||||
status: args.status,
|
||||
isUserSet: args.isUserSet,
|
||||
},
|
||||
export const getUserStatus = query({
|
||||
handler: async (ctx) => {
|
||||
const { userId } = await userValidation(ctx);
|
||||
const userStatus = await ctx.db.query("userStatus").withIndex("userId", (q) => q.eq("userId", userId)).first();
|
||||
return userStatus;
|
||||
}
|
||||
});
|
||||
|
||||
export const updateUserMetadata = mutation({
|
||||
args: {
|
||||
metadata: v.object({
|
||||
phrasePreference: v.union(v.literal("comforting"), v.literal("mocking"), v.literal("both")),
|
||||
}),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const { userId } = await userValidation(ctx);
|
||||
|
||||
return ctx.db.patch("user", userId, {
|
||||
metadata: args.metadata,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
})
|
||||
|
|
@ -10,6 +10,7 @@
|
|||
"start:server": "NODE_ENV=development tsx src/server.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@better-fetch/fetch": "^1.1.21",
|
||||
"@convex-dev/better-auth": "^0.10.4",
|
||||
"@marsidev/react-turnstile": "^1.4.0",
|
||||
"@matrix-org/olm": "^3.2.15",
|
||||
|
|
|
|||
146
src/app/page.tsx
146
src/app/page.tsx
|
|
@ -1,5 +1,6 @@
|
|||
"use client"
|
||||
import AppSidebar from "@/components/home";
|
||||
import FriendRequestModal from "@/components/home/modals/friendRequest";
|
||||
import OlmSetupDialog from "@/components/olm/olm-setup-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
|
@ -9,11 +10,10 @@ import { useOlmSetup } from "@/hooks/use-olm-setup";
|
|||
import { useSocket } from "@/hooks/use-socket";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import { useMutation, useQuery } from "convex/react";
|
||||
import { PlusIcon, SearchIcon, UsersIcon } from "lucide-react";
|
||||
import { PlusIcon, SearchIcon, SettingsIcon, UsersIcon } from "lucide-react";
|
||||
import { redirect } from "next/navigation";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
|
||||
const mockPhrases = [
|
||||
"No bitches? Womp womp",
|
||||
"You're all alone",
|
||||
|
|
@ -94,9 +94,9 @@ const comfortingPhrases = [
|
|||
];
|
||||
|
||||
export default function Home() {
|
||||
const { data, error, isPending } = authClient.useSession();
|
||||
const { data, error, isPending, refetch } = authClient.useSession();
|
||||
|
||||
const [page, setPage] = useState<"friends" | "settings">("friends");
|
||||
const [page, setPage] = useState<"friends" | "support">("friends");
|
||||
const [currentChannel, setCurrentChannel] = useState<SiPher.Channel | null>(null);
|
||||
const [openDmChannels, setOpenDmChannels] = useState<SiPher.Channel[] | []>([]);
|
||||
const [availableServers, setAvailableServers] = useState<SiPher.Server[] | []>([]);
|
||||
|
|
@ -104,29 +104,55 @@ export default function Home() {
|
|||
// Friends page state
|
||||
const [friendsPage, setFriendsPage] = useState<"all" | "available">("all");
|
||||
const [friendsSearch, setFriendsSearch] = useState<string>("");
|
||||
const [friendModal, setFriendModal] = useState<boolean>(false);
|
||||
|
||||
const hasServerOlm = useQuery(
|
||||
api.auth.retrieveServerOlmAccount,
|
||||
data?.user?.id ? { userId: data.user.id } : "skip"
|
||||
);
|
||||
|
||||
// Get user status from separate table
|
||||
const userStatus = useQuery(api.auth.getUserStatus);
|
||||
|
||||
// Get friends list (reactive)
|
||||
const friends = useQuery(api.auth.getFriends);
|
||||
|
||||
// Type for friends
|
||||
type Friend = NonNullable<typeof friends>[number];
|
||||
|
||||
// Mutation for sending keys to server
|
||||
const sendKeysToServer = useMutation(api.auth.sendKeysToServer);
|
||||
|
||||
const updateUserStatus = useMutation(api.auth.updateUserStatus);
|
||||
|
||||
const updateUserMetadata = useMutation(api.auth.updateUserMetadata);
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
|
||||
const status = data.user.status
|
||||
if (!status) return;
|
||||
|
||||
if (status.status === "offline" && !status.isUserSet) {
|
||||
updateUserStatus({ status: "online", isUserSet: false });
|
||||
const metadata = data.user.metadata
|
||||
if (!metadata) {
|
||||
console.debug(
|
||||
"[Home] > User metadata set",
|
||||
data.user.metadata
|
||||
)
|
||||
updateUserMetadata({ metadata: { phrasePreference: "comforting" } });
|
||||
return
|
||||
}
|
||||
}, [data?.user?.id, updateUserStatus, data?.user?.status]);
|
||||
}, [data, updateUserMetadata]);
|
||||
|
||||
// Custom hooks for socket and OLM management
|
||||
const { socketStatus, socketInfo } = useSocket(data?.user?.id);
|
||||
const { socketStatus, socketInfo, disconnect, connect } = useSocket({
|
||||
user: {
|
||||
id: data?.user?.id,
|
||||
status: userStatus ? {
|
||||
status: userStatus.status,
|
||||
isUserSet: userStatus.isUserSet,
|
||||
} : {
|
||||
status: "offline" as const,
|
||||
isUserSet: false,
|
||||
},
|
||||
},
|
||||
refetchUser: refetch
|
||||
});
|
||||
const { olmStatus, showOlmModal, setShowOlmModal, handleCreateAccount } = useOlmSetup({
|
||||
userId: data?.user?.id,
|
||||
hasServerOlm,
|
||||
|
|
@ -140,10 +166,9 @@ export default function Home() {
|
|||
}
|
||||
|
||||
if (error || !data) {
|
||||
return redirect(`/auth${error ? `?error=${error.cause}` : "no-data"}`);
|
||||
return redirect(`/auth${error ? `?error=${error.cause}` : "?error=no-data"}`);
|
||||
}
|
||||
|
||||
|
||||
const getRandomPhrase = useCallback(() => {
|
||||
const phrases = {
|
||||
comforting: comfortingPhrases,
|
||||
|
|
@ -161,7 +186,7 @@ export default function Home() {
|
|||
return (
|
||||
<>
|
||||
<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">
|
||||
{/* 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">
|
||||
|
|
@ -183,24 +208,28 @@ export default function Home() {
|
|||
}
|
||||
</div>
|
||||
{/* 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">
|
||||
<UsersIcon className="size-4" />
|
||||
<span className="text-sm font-medium">Friends</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium">•</span>
|
||||
<div className="flex flex-row gap-2 h-full">
|
||||
<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")}>
|
||||
Available
|
||||
</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")}>
|
||||
All Known
|
||||
</Button>
|
||||
<Button variant="ghost" className="h-full bg-primary text-primary-foreground hover:cursor-pointer justify-start p-2 ">
|
||||
Add Friend
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{
|
||||
page === "friends" ? (
|
||||
<div className="flex flex-row justify-start items-center gap-2 w-full">
|
||||
<div className="flex flex-row gap-2 justify-start p-2">
|
||||
<UsersIcon className="size-4" />
|
||||
<span className="text-sm font-medium">Friends</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium">•</span>
|
||||
<div className="flex flex-row gap-2 h-full">
|
||||
<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")}>
|
||||
Available
|
||||
</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")}>
|
||||
All Known
|
||||
</Button>
|
||||
<Button variant="ghost" className="h-full bg-primary text-primary-foreground hover:cursor-pointer justify-start p-2" onClick={() => setFriendModal(true)}>
|
||||
Add Friend
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
</div>
|
||||
{/* Content Area - Channel List + Main Content */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
|
|
@ -213,6 +242,10 @@ export default function Home() {
|
|||
<UsersIcon className="size-4" />
|
||||
<span className="text-sm font-medium">Friends</span>
|
||||
</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 className="w-[calc(100%-0.8rem)] h-px bg-border/40 mx-2" />
|
||||
|
|
@ -269,19 +302,42 @@ export default function Home() {
|
|||
/>
|
||||
{
|
||||
friendsPage === "all" ? (
|
||||
<div className="flex items-center min-h-10 max-h-10">
|
||||
<span className="text-sm font-medium">All Friends</span>
|
||||
<div className="flex flex-col items-start w-full p-2 gap-2 pt-4">
|
||||
<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 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 ? (
|
||||
data.user.friends.map((friend) => (
|
||||
<div className="flex items-center min-h-10 max-h-10">
|
||||
<span className="text-sm font-medium">{friend}</span>
|
||||
</div>
|
||||
))
|
||||
friends && friends.length > 0 ? (
|
||||
friends
|
||||
.filter((f: Friend) => f && f.status?.status !== "offline")
|
||||
.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()}
|
||||
|
|
@ -293,7 +349,7 @@ export default function Home() {
|
|||
}
|
||||
</div>
|
||||
</div>
|
||||
) : page === "settings" ? (
|
||||
) : page === "support" ? (
|
||||
<div className="flex flex-col flex-1 overflow-y-auto p-4">
|
||||
<div className="flex items-center min-h-10 max-h-10">
|
||||
<span className="text-sm font-medium">Servers</span>
|
||||
|
|
@ -306,6 +362,10 @@ export default function Home() {
|
|||
</div>
|
||||
</AppSidebar>
|
||||
|
||||
<FriendRequestModal
|
||||
open={friendModal}
|
||||
onOpenChange={setFriendModal}
|
||||
/>
|
||||
{/* OLM Account Setup/Sync Modal */}
|
||||
<OlmSetupDialog
|
||||
open={showOlmModal}
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@
|
|||
|
||||
import { cn } from "@/lib/utils";
|
||||
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 { Button } from "../ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
|
||||
|
||||
function formatUptime(ms: number): string {
|
||||
|
|
@ -19,7 +20,7 @@ function formatUptime(ms: number): string {
|
|||
/**
|
||||
* 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 [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
|
|
@ -188,10 +189,21 @@ export default function ConnectionStatusIndicator({ socketStatus, socketInfo }:
|
|||
</div>
|
||||
|
||||
{/* 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">
|
||||
Real-time connection via Socket.IO
|
||||
</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>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ const SidebarItems: SiPher.SidebarItem[] = [
|
|||
* It also is the controller for everything on the app, including going to other pages, showing conversations and other.
|
||||
* @param children - The children to be rendered in the sidebar inset
|
||||
*/
|
||||
export default function AppSidebar({ children, socketStatus, socketInfo, currentChannel }: SiPher.AppSidebarProps) {
|
||||
export default function AppSidebar({ children, socketStatus, socketInfo, currentChannel, disconnectSocket, connectSocket }: SiPher.AppSidebarProps) {
|
||||
const [activeItem, setActiveItem] = useState<string>("home");
|
||||
|
||||
return (
|
||||
|
|
@ -120,7 +120,7 @@ export default function AppSidebar({ children, socketStatus, socketInfo, current
|
|||
}
|
||||
</div>
|
||||
{/* Socket connection status */}
|
||||
<ConnectionStatusIndicator socketStatus={socketStatus} socketInfo={socketInfo} />
|
||||
<ConnectionStatusIndicator socketStatus={socketStatus} socketInfo={socketInfo} disconnectSocket={disconnectSocket} connectSocket={connectSocket} />
|
||||
</div>
|
||||
<div className="w-9 md:hidden" /> {/* Spacer for centering on mobile */}
|
||||
</header>
|
||||
|
|
|
|||
305
src/components/home/modals/friendRequest.tsx
Normal file
305
src/components/home/modals/friendRequest.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -6,7 +6,9 @@ import {
|
|||
GearSix,
|
||||
MicrophoneSlash
|
||||
} from "@phosphor-icons/react";
|
||||
import { useQuery } from "convex/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { api } from "../../../../convex/_generated/api";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "../avatar";
|
||||
import { Button } from "../button";
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "../hover-card";
|
||||
|
|
@ -22,19 +24,21 @@ interface UserFloatingCardProps {
|
|||
|
||||
const statusColors: Record<UserStatus, string> = {
|
||||
online: "bg-emerald-500",
|
||||
busy: "bg-amber-500",
|
||||
busy: "bg-red-500",
|
||||
away: "bg-yellow-500",
|
||||
offline: "bg-muted-foreground"
|
||||
};
|
||||
|
||||
export default function UserFloatingCard({
|
||||
user,
|
||||
}: UserFloatingCardProps) {
|
||||
export default function UserFloatingCard(
|
||||
{ user }: UserFloatingCardProps
|
||||
) {
|
||||
const [cardOpen, setCardOpen] = useState(false);
|
||||
const triggerRef = useRef<HTMLButtonElement | null>(null);
|
||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||
const status = user.status?.status;
|
||||
const activity = user.status?.activity;
|
||||
const status = useQuery(api.auth.getUserStatus) as {
|
||||
status: "online" | "busy" | "offline" | "away";
|
||||
isUserSet: boolean;
|
||||
} | null;
|
||||
|
||||
// Close when clicking outside the trigger/content
|
||||
useEffect(() => {
|
||||
|
|
@ -113,7 +117,7 @@ export default function UserFloatingCard({
|
|||
<span
|
||||
className={cn(
|
||||
"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>
|
||||
|
|
@ -124,19 +128,12 @@ export default function UserFloatingCard({
|
|||
{user.name}
|
||||
</span>
|
||||
</div>
|
||||
{activity ? (
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground truncate">
|
||||
<span className="text-[14px] leading-none">{"\u2022"}</span>
|
||||
<span className="inline-flex items-center gap-1 text-[13px]">
|
||||
<span className="text-foreground/80">{activity}</span>
|
||||
</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 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>
|
||||
</Button>
|
||||
</HoverCardTrigger>
|
||||
|
|
@ -156,10 +153,8 @@ export default function UserFloatingCard({
|
|||
</Avatar>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<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">
|
||||
{activity ?? "Activity status (coming soon)"}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground truncate capitalize">{status?.status}</span>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,26 @@
|
|||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation } from "convex/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { io, Socket } from "socket.io-client";
|
||||
import { api } from "../../convex/_generated/api";
|
||||
|
||||
interface UseSocketProps {
|
||||
user: {
|
||||
id?: string;
|
||||
status: {
|
||||
status: "online" | "busy" | "offline" | "away";
|
||||
isUserSet: boolean;
|
||||
}
|
||||
}
|
||||
refetchUser: () => void;
|
||||
}
|
||||
|
||||
export function useSocket({ user, refetchUser }: UseSocketProps) {
|
||||
const updateUserStatus = useMutation(api.auth.updateUserStatus);
|
||||
const socketRef = useRef<Socket | null>(null);
|
||||
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
export function useSocket(userId: string | undefined) {
|
||||
const [socketStatus, setSocketStatus] = useState<SiPher.SocketStatus>("connecting");
|
||||
const [socketInfo, setSocketInfo] = useState<SiPher.SocketInfo>({
|
||||
ping: null,
|
||||
|
|
@ -14,21 +31,81 @@ export function useSocket(userId: string | undefined) {
|
|||
error: null
|
||||
});
|
||||
|
||||
// Manual disconnect function
|
||||
const disconnect = useCallback(() => {
|
||||
if (socketRef.current) {
|
||||
console.log("🔌 Manually disconnecting socket...");
|
||||
socketRef.current.disconnect();
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
setSocketStatus("disconnected");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const connect = useCallback(() => {
|
||||
if (socketRef.current) {
|
||||
socketRef.current.connect();
|
||||
refetchUser();
|
||||
}
|
||||
}, [refetchUser]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
if (!user.id) return;
|
||||
|
||||
const socket: Socket = io({ withCredentials: false });
|
||||
let pingInterval: NodeJS.Timeout | null = null;
|
||||
socketRef.current = socket;
|
||||
|
||||
// Measure ping latency
|
||||
// Measure ping latency using acknowledgment callback
|
||||
const measurePing = () => {
|
||||
const start = Date.now();
|
||||
socket.volatile.emit("ping", () => {
|
||||
const latency = Date.now() - start;
|
||||
const clientTimestamp = Date.now();
|
||||
|
||||
// Use acknowledgment callback for reliable latency measurement
|
||||
socket.timeout(5000).emit("ping", (err: Error, serverTimestamp: number) => {
|
||||
if (err) {
|
||||
console.warn("[Socket] Ping timeout or error:", err);
|
||||
setSocketInfo((prev: SiPher.SocketInfo) => ({ ...prev, ping: null }));
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const latency = now - clientTimestamp;
|
||||
console.log("[Socket] Ping latency:", latency);
|
||||
setSocketInfo((prev: SiPher.SocketInfo) => ({ ...prev, ping: latency }));
|
||||
});
|
||||
};
|
||||
|
||||
function setUserDefaultStatus(
|
||||
newStatus: "online" | "busy" | "offline" | "away",
|
||||
oldStatus?: {
|
||||
status: "online" | "busy" | "offline" | "away";
|
||||
isUserSet: boolean;
|
||||
}
|
||||
) {
|
||||
if (!oldStatus) {
|
||||
console.log("🔌 User default status set to online");
|
||||
updateUserStatus({ status: "online", isUserSet: false });
|
||||
refetchUser();
|
||||
return;
|
||||
}
|
||||
|
||||
if (newStatus === "offline") {
|
||||
updateUserStatus({ status: newStatus, isUserSet: oldStatus.isUserSet });
|
||||
refetchUser();
|
||||
return;
|
||||
} else if (!oldStatus.isUserSet) {
|
||||
console.log("🔌 User default status set to online");
|
||||
updateUserStatus({ status: newStatus, isUserSet: oldStatus.isUserSet });
|
||||
refetchUser();
|
||||
return;
|
||||
} else {
|
||||
updateUserStatus({ status: oldStatus.status, isUserSet: oldStatus.isUserSet });
|
||||
refetchUser();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
socket.on("connect", () => {
|
||||
console.log("✅ Connected to socket - Authentication successful!");
|
||||
setSocketStatus("connected");
|
||||
|
|
@ -41,9 +118,11 @@ export function useSocket(userId: string | undefined) {
|
|||
error: null
|
||||
}));
|
||||
|
||||
// Start ping measurement every 5 seconds
|
||||
setUserDefaultStatus("online", user.status);
|
||||
|
||||
// Start ping measurement every 5 seconds for latency display
|
||||
measurePing();
|
||||
pingInterval = setInterval(measurePing, 5000);
|
||||
pingIntervalRef.current = setInterval(measurePing, 5000);
|
||||
});
|
||||
|
||||
// Update transport when it upgrades (polling -> websocket)
|
||||
|
|
@ -53,6 +132,7 @@ export function useSocket(userId: string | undefined) {
|
|||
|
||||
socket.on("connect_error", (err) => {
|
||||
console.error("❌ Socket connection error:", err.message);
|
||||
setUserDefaultStatus("offline", user.status);
|
||||
setSocketStatus("error");
|
||||
setSocketInfo((prev: SiPher.SocketInfo) => ({
|
||||
...prev,
|
||||
|
|
@ -65,6 +145,7 @@ export function useSocket(userId: string | undefined) {
|
|||
|
||||
socket.on("disconnect", (reason) => {
|
||||
console.log("🔌 Disconnected from socket:", reason);
|
||||
setUserDefaultStatus("offline", user.status);
|
||||
setSocketStatus("disconnected");
|
||||
setSocketInfo((prev: SiPher.SocketInfo) => ({
|
||||
...prev,
|
||||
|
|
@ -72,7 +153,10 @@ export function useSocket(userId: string | undefined) {
|
|||
connectedAt: null,
|
||||
error: reason
|
||||
}));
|
||||
if (pingInterval) clearInterval(pingInterval);
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle pong response for ping measurement
|
||||
|
|
@ -81,11 +165,14 @@ export function useSocket(userId: string | undefined) {
|
|||
});
|
||||
|
||||
return () => {
|
||||
if (pingInterval) clearInterval(pingInterval);
|
||||
if (pingIntervalRef.current) {
|
||||
clearInterval(pingIntervalRef.current);
|
||||
pingIntervalRef.current = null;
|
||||
}
|
||||
socket.disconnect();
|
||||
};
|
||||
}, [userId]);
|
||||
}, [user.id, updateUserStatus]);
|
||||
|
||||
return { socketStatus, socketInfo };
|
||||
return { socketStatus, socketInfo, disconnect, connect };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ export const authClient = createAuthClient({
|
|||
convexClient(),
|
||||
usernameClient(),
|
||||
oneTimeTokenClient(),
|
||||
inferAdditionalFields<typeof auth>()
|
||||
inferAdditionalFields<typeof auth>(),
|
||||
],
|
||||
sessionOptions: {
|
||||
refetchOnWindowFocus: false,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ interface DmMessage {
|
|||
|
||||
const dmEvent: SiPher.EventsType = {
|
||||
name: "dm",
|
||||
description: "Send a direct message to another user",
|
||||
description: "Send a direct message to another user using the client-side encryption",
|
||||
category: "user",
|
||||
type: "message",
|
||||
handler: (socket: Socket, io: SocketIOServer, data: DmMessage) => {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ export default {
|
|||
handler: (socket: Socket, io: SocketIOServer, ...args: any[]) => {
|
||||
console.log("Message received", args)
|
||||
},
|
||||
description: "A message event",
|
||||
category: "user",
|
||||
description: "Send a message to a channel by using the server-side encryption",
|
||||
category: "server",
|
||||
type: "message"
|
||||
} satisfies SiPher.EventsType
|
||||
24
src/lib/sockets/events/ping.ts
Normal file
24
src/lib/sockets/events/ping.ts
Normal 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;
|
||||
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
* @fileoverview Socket Manager Class for handling socket connections and events at the server side.
|
||||
*/
|
||||
|
||||
import { Session, User } from "better-auth";
|
||||
import { existsSync, readdirSync } from "fs";
|
||||
import type { Server as HTTPServer } from "http";
|
||||
import path from "path";
|
||||
|
|
@ -41,7 +42,11 @@ export default class SocketManager {
|
|||
};
|
||||
|
||||
if (!this.socketIo) {
|
||||
this.socketIo = new SocketIOServer(nextServer)
|
||||
this.socketIo = new SocketIOServer(nextServer, {
|
||||
// Configure Socket.IO's built-in heartbeat mechanism
|
||||
pingInterval: 25000, // Server sends ping every 25 seconds
|
||||
pingTimeout: 60000, // Close connection if no pong received within 60 seconds
|
||||
});
|
||||
}
|
||||
|
||||
if (this.options.requireAuth) {
|
||||
|
|
@ -54,7 +59,7 @@ export default class SocketManager {
|
|||
|
||||
this.socketIo.use(async (socket, next) => {
|
||||
try {
|
||||
let result: { user?: unknown; session?: unknown } | null = null;
|
||||
let result: { user?: User, session?: Session } | null = null;
|
||||
|
||||
if (this.options.authMethod === "ott") {
|
||||
// OTT-based auth: client must provide token in auth object
|
||||
|
|
@ -94,14 +99,15 @@ export default class SocketManager {
|
|||
return next(new Error("Authentication error: Invalid session"));
|
||||
}
|
||||
|
||||
const user = result.user as { id: string; email: string; name?: string };
|
||||
const { user, session } = result;
|
||||
|
||||
// Set socket.id to user ID for persistent identification
|
||||
(socket as any).id = user.id;
|
||||
// @ts-expect-error: This should be a readonly property, but IDGAF, if it breaks, it breaks :D
|
||||
socket.id = user.id;
|
||||
|
||||
// Attach user and session to socket for use in event handlers
|
||||
(socket as any).user = user;
|
||||
(socket as any).session = result.session;
|
||||
socket.user = user;
|
||||
socket.session = session;
|
||||
|
||||
next();
|
||||
} catch (error) {
|
||||
|
|
|
|||
21
src/types/globals.d.ts
vendored
21
src/types/globals.d.ts
vendored
|
|
@ -1,3 +1,4 @@
|
|||
import { Session, User } from "better-auth";
|
||||
import { Socket, Server as SocketIOServer } from "socket.io";
|
||||
|
||||
declare global {
|
||||
|
|
@ -111,16 +112,18 @@ declare global {
|
|||
id: string,
|
||||
system: System
|
||||
}
|
||||
}
|
||||
|
||||
type MessageEvent = {
|
||||
message: {
|
||||
/** Will either be a raw string or a encrypted blob, if it is a encrypted blob, the iv will be provided */
|
||||
content: string,
|
||||
iv?: string
|
||||
},
|
||||
from: SipherUser,
|
||||
recipient: MessageRecipient
|
||||
}
|
||||
// Add custom socket.io types
|
||||
}
|
||||
|
||||
// Extend Socket.io types to include authenticated user data
|
||||
declare module "socket.io" {
|
||||
interface Socket {
|
||||
/** Authenticated user from Better Auth (set after auth middleware) */
|
||||
user?: User;
|
||||
/** Session data from Better Auth (set after auth middleware) */
|
||||
session?: Session;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
20
src/types/messages/encrypted.d.ts
vendored
Normal file
20
src/types/messages/encrypted.d.ts
vendored
Normal 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
24
src/types/messages/unencrypted.d.ts
vendored
Normal 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 { };
|
||||
|
||||
2
src/types/sidebar.d.ts
vendored
2
src/types/sidebar.d.ts
vendored
|
|
@ -6,6 +6,8 @@ declare global {
|
|||
socketStatus: SocketStatus;
|
||||
socketInfo: SocketInfo;
|
||||
currentChannel?: SiPher.Channel;
|
||||
disconnectSocket: () => void;
|
||||
connectSocket: () => void;
|
||||
}
|
||||
|
||||
interface SidebarItem {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue