mirror of
https://github.com/monkeytypegame/monkeytype.git
synced 2026-01-05 06:54:36 +08:00
feat: add friend requests and list (@fehmer) (#6658)
make some friends on monkeytype --------- Co-authored-by: Miodec <jack@monkeytype.com>
This commit is contained in:
parent
9aa30a2a41
commit
d885e70232
63 changed files with 3335 additions and 115 deletions
365
backend/__tests__/__integration__/dal/connections.spec.ts
Normal file
365
backend/__tests__/__integration__/dal/connections.spec.ts
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
import {
|
||||
describe,
|
||||
it,
|
||||
expect,
|
||||
vi,
|
||||
beforeAll,
|
||||
beforeEach,
|
||||
afterEach,
|
||||
} from "vitest";
|
||||
import { ObjectId } from "mongodb";
|
||||
|
||||
import * as ConnectionsDal from "../../../src/dal/connections";
|
||||
import { createConnection } from "../../__testData__/connections";
|
||||
|
||||
describe("ConnectionsDal", () => {
|
||||
beforeAll(async () => {
|
||||
await ConnectionsDal.createIndicies();
|
||||
});
|
||||
|
||||
describe("getRequests", () => {
|
||||
it("get by uid", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const initOne = await createConnection({ initiatorUid: uid });
|
||||
const initTwo = await createConnection({ initiatorUid: uid });
|
||||
const friendOne = await createConnection({ receiverUid: uid });
|
||||
const _decoy = await createConnection({});
|
||||
|
||||
//WHEN / THEM
|
||||
|
||||
expect(
|
||||
await ConnectionsDal.getConnections({
|
||||
initiatorUid: uid,
|
||||
receiverUid: uid,
|
||||
})
|
||||
).toStrictEqual([initOne, initTwo, friendOne]);
|
||||
});
|
||||
|
||||
it("get by uid and status", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const initAccepted = await createConnection({
|
||||
initiatorUid: uid,
|
||||
status: "accepted",
|
||||
});
|
||||
const _initPending = await createConnection({
|
||||
initiatorUid: uid,
|
||||
status: "pending",
|
||||
});
|
||||
const initBlocked = await createConnection({
|
||||
initiatorUid: uid,
|
||||
status: "blocked",
|
||||
});
|
||||
|
||||
const friendAccepted = await createConnection({
|
||||
receiverUid: uid,
|
||||
status: "accepted",
|
||||
});
|
||||
const _friendPending = await createConnection({
|
||||
receiverUid: uid,
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
const _decoy = await createConnection({ status: "accepted" });
|
||||
|
||||
//WHEN / THEN
|
||||
|
||||
expect(
|
||||
await ConnectionsDal.getConnections({
|
||||
initiatorUid: uid,
|
||||
receiverUid: uid,
|
||||
status: ["accepted", "blocked"],
|
||||
})
|
||||
).toStrictEqual([initAccepted, initBlocked, friendAccepted]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
const now = 1715082588;
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(now);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("should fail creating duplicates", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const first = await createConnection({
|
||||
initiatorUid: uid,
|
||||
});
|
||||
|
||||
//WHEN/THEN
|
||||
await expect(
|
||||
createConnection({
|
||||
initiatorUid: first.receiverUid,
|
||||
receiverUid: uid,
|
||||
})
|
||||
).rejects.toThrow("Connection request already sent");
|
||||
});
|
||||
|
||||
it("should create", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const receiverUid = new ObjectId().toHexString();
|
||||
|
||||
//WHEN
|
||||
const created = await ConnectionsDal.create(
|
||||
{ uid, name: "Bob" },
|
||||
{ uid: receiverUid, name: "Kevin" },
|
||||
2
|
||||
);
|
||||
|
||||
//THEN
|
||||
expect(created).toEqual({
|
||||
_id: created._id,
|
||||
initiatorUid: uid,
|
||||
initiatorName: "Bob",
|
||||
receiverUid: receiverUid,
|
||||
receiverName: "Kevin",
|
||||
lastModified: now,
|
||||
status: "pending",
|
||||
key: `${uid}/${receiverUid}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail if maximum connections are reached", async () => {
|
||||
//GIVEN
|
||||
const initiatorUid = new ObjectId().toHexString();
|
||||
await createConnection({ initiatorUid });
|
||||
await createConnection({ initiatorUid });
|
||||
|
||||
//WHEN / THEM
|
||||
await expect(createConnection({ initiatorUid }, 2)).rejects.toThrow(
|
||||
"Maximum number of connections reached\nStack: create connection request"
|
||||
);
|
||||
});
|
||||
|
||||
it("should fail creating if blocked", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const first = await createConnection({
|
||||
initiatorUid: uid,
|
||||
status: "blocked",
|
||||
});
|
||||
|
||||
//WHEN/THEN
|
||||
await expect(
|
||||
createConnection({
|
||||
initiatorUid: first.receiverUid,
|
||||
receiverUid: uid,
|
||||
})
|
||||
).rejects.toThrow("Connection blocked");
|
||||
});
|
||||
});
|
||||
describe("updateStatus", () => {
|
||||
const now = 1715082588;
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(now);
|
||||
});
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
it("should update the status", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const first = await createConnection({
|
||||
receiverUid: uid,
|
||||
lastModified: 100,
|
||||
});
|
||||
const second = await createConnection({
|
||||
receiverUid: uid,
|
||||
lastModified: 200,
|
||||
});
|
||||
|
||||
//WHEN
|
||||
await ConnectionsDal.updateStatus(
|
||||
uid,
|
||||
first._id.toHexString(),
|
||||
"accepted"
|
||||
);
|
||||
|
||||
//THEN
|
||||
expect(await ConnectionsDal.getConnections({ receiverUid: uid })).toEqual(
|
||||
[{ ...first, status: "accepted", lastModified: now }, second]
|
||||
);
|
||||
|
||||
//can update twice to the same status
|
||||
await ConnectionsDal.updateStatus(
|
||||
uid,
|
||||
first._id.toHexString(),
|
||||
"accepted"
|
||||
);
|
||||
});
|
||||
it("should fail if uid does not match the reeceiverUid", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const first = await createConnection({
|
||||
initiatorUid: uid,
|
||||
});
|
||||
|
||||
//WHEN / THEN
|
||||
await expect(
|
||||
ConnectionsDal.updateStatus(uid, first._id.toHexString(), "accepted")
|
||||
).rejects.toThrow("No permission or connection not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteById", () => {
|
||||
it("should delete by initiator", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const first = await createConnection({
|
||||
initiatorUid: uid,
|
||||
});
|
||||
const second = await createConnection({
|
||||
initiatorUid: uid,
|
||||
});
|
||||
|
||||
//WHEN
|
||||
await ConnectionsDal.deleteById(uid, first._id.toHexString());
|
||||
|
||||
//THEN
|
||||
expect(
|
||||
await ConnectionsDal.getConnections({ initiatorUid: uid })
|
||||
).toStrictEqual([second]);
|
||||
});
|
||||
|
||||
it("should delete by receiver", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const first = await createConnection({
|
||||
receiverUid: uid,
|
||||
});
|
||||
const second = await createConnection({
|
||||
receiverUid: uid,
|
||||
status: "accepted",
|
||||
});
|
||||
|
||||
//WHEN
|
||||
await ConnectionsDal.deleteById(uid, first._id.toHexString());
|
||||
|
||||
//THEN
|
||||
expect(
|
||||
await ConnectionsDal.getConnections({
|
||||
initiatorUid: second.initiatorUid,
|
||||
})
|
||||
).toStrictEqual([second]);
|
||||
});
|
||||
|
||||
it("should fail if uid does not match", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const first = await createConnection({
|
||||
initiatorUid: uid,
|
||||
});
|
||||
|
||||
//WHEN / THEN
|
||||
await expect(
|
||||
ConnectionsDal.deleteById("Bob", first._id.toHexString())
|
||||
).rejects.toThrow("No permission or connection not found");
|
||||
});
|
||||
|
||||
it("should fail if initiator deletes blocked by receiver", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const myRequestWasBlocked = await createConnection({
|
||||
initiatorName: uid,
|
||||
status: "blocked",
|
||||
});
|
||||
|
||||
//WHEN / THEN
|
||||
await expect(
|
||||
ConnectionsDal.deleteById(uid, myRequestWasBlocked._id.toHexString())
|
||||
).rejects.toThrow("No permission or connection not found");
|
||||
});
|
||||
it("allow receiver to delete blocked", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const myBlockedUser = await createConnection({
|
||||
receiverUid: uid,
|
||||
status: "blocked",
|
||||
});
|
||||
|
||||
//WHEN
|
||||
await ConnectionsDal.deleteById(uid, myBlockedUser._id.toHexString());
|
||||
|
||||
//THEN
|
||||
expect(await ConnectionsDal.getConnections({ receiverUid: uid })).toEqual(
|
||||
[]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deleteByUid", () => {
|
||||
it("should delete by uid", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const _initOne = await createConnection({ initiatorUid: uid });
|
||||
const _initTwo = await createConnection({ initiatorUid: uid });
|
||||
const _friendOne = await createConnection({ receiverUid: uid });
|
||||
const decoy = await createConnection({});
|
||||
|
||||
//WHEN
|
||||
await ConnectionsDal.deleteByUid(uid);
|
||||
|
||||
//THEN
|
||||
expect(
|
||||
await ConnectionsDal.getConnections({
|
||||
initiatorUid: uid,
|
||||
receiverUid: uid,
|
||||
})
|
||||
).toEqual([]);
|
||||
|
||||
expect(
|
||||
await ConnectionsDal.getConnections({
|
||||
initiatorUid: decoy.initiatorUid,
|
||||
})
|
||||
).toEqual([decoy]);
|
||||
});
|
||||
});
|
||||
describe("updateName", () => {
|
||||
it("should update the name", async () => {
|
||||
//GIVEN
|
||||
const uid = new ObjectId().toHexString();
|
||||
const initOne = await createConnection({
|
||||
initiatorUid: uid,
|
||||
initiatorName: "Bob",
|
||||
});
|
||||
const initTwo = await createConnection({
|
||||
initiatorUid: uid,
|
||||
initiatorName: "Bob",
|
||||
});
|
||||
const friendOne = await createConnection({
|
||||
receiverUid: uid,
|
||||
receiverName: "Bob",
|
||||
});
|
||||
const decoy = await createConnection({});
|
||||
|
||||
//WHEN
|
||||
await ConnectionsDal.updateName(uid, "King Bob");
|
||||
|
||||
//THEN
|
||||
expect(
|
||||
await ConnectionsDal.getConnections({
|
||||
initiatorUid: uid,
|
||||
receiverUid: uid,
|
||||
})
|
||||
).toEqual([
|
||||
{ ...initOne, initiatorName: "King Bob" },
|
||||
{ ...initTwo, initiatorName: "King Bob" },
|
||||
{ ...friendOne, receiverName: "King Bob" },
|
||||
]);
|
||||
|
||||
expect(
|
||||
await ConnectionsDal.getConnections({
|
||||
initiatorUid: decoy.initiatorUid,
|
||||
})
|
||||
).toEqual([decoy]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -382,7 +382,7 @@ describe("PresetDal", () => {
|
|||
).presetId;
|
||||
|
||||
//WHEN
|
||||
PresetDal.removePreset(uid, first);
|
||||
await PresetDal.removePreset(uid, first);
|
||||
|
||||
//THEN
|
||||
const read = await PresetDal.getPresets(uid);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest";
|
|||
import _ from "lodash";
|
||||
import * as UserDAL from "../../../src/dal/user";
|
||||
import * as UserTestData from "../../__testData__/users";
|
||||
import { createConnection as createFriend } from "../../__testData__/connections";
|
||||
import { ObjectId } from "mongodb";
|
||||
import { MonkeyMail, ResultFilters } from "@monkeytype/schemas/users";
|
||||
import { PersonalBest, PersonalBests } from "@monkeytype/schemas/shared";
|
||||
|
|
@ -1980,4 +1981,121 @@ describe("UserDal", () => {
|
|||
expect(read.streak?.hourOffset).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getFriends", () => {
|
||||
it("get list of friends", async () => {
|
||||
//GIVEN
|
||||
const me = await UserTestData.createUser({ name: "Me" });
|
||||
const uid = me.uid;
|
||||
|
||||
const friendOne = await UserTestData.createUser({
|
||||
name: "One",
|
||||
personalBests: {
|
||||
time: {
|
||||
"15": [UserTestData.pb(100)],
|
||||
"60": [UserTestData.pb(85), UserTestData.pb(90)],
|
||||
},
|
||||
} as any,
|
||||
inventory: {
|
||||
badges: [{ id: 42, selected: true }, { id: 23 }, { id: 5 }],
|
||||
},
|
||||
banned: true,
|
||||
lbOptOut: true,
|
||||
premium: { expirationTimestamp: -1 } as any,
|
||||
});
|
||||
const friendOneRequest = await createFriend({
|
||||
initiatorUid: uid,
|
||||
receiverUid: friendOne.uid,
|
||||
status: "accepted",
|
||||
lastModified: 100,
|
||||
});
|
||||
const friendTwo = await UserTestData.createUser({
|
||||
name: "Two",
|
||||
discordId: "discordId",
|
||||
discordAvatar: "discordAvatar",
|
||||
timeTyping: 600,
|
||||
startedTests: 150,
|
||||
completedTests: 125,
|
||||
streak: {
|
||||
length: 10,
|
||||
maxLength: 50,
|
||||
lastResultTimestamp: 0,
|
||||
hourOffset: -1,
|
||||
} as any,
|
||||
xp: 42,
|
||||
inventory: {
|
||||
badges: [{ id: 23 }, { id: 5 }],
|
||||
},
|
||||
premium: {
|
||||
expirationTimestamp: vi.getRealSystemTime() + 5000,
|
||||
} as any,
|
||||
});
|
||||
const friendTwoRequest = await createFriend({
|
||||
initiatorUid: uid,
|
||||
receiverUid: friendTwo.uid,
|
||||
status: "accepted",
|
||||
lastModified: 200,
|
||||
});
|
||||
|
||||
const friendThree = await UserTestData.createUser({ name: "Three" });
|
||||
const friendThreeRequest = await createFriend({
|
||||
receiverUid: uid,
|
||||
initiatorUid: friendThree.uid,
|
||||
status: "accepted",
|
||||
lastModified: 300,
|
||||
});
|
||||
|
||||
//non accepted
|
||||
await createFriend({ receiverUid: uid, status: "pending" });
|
||||
await createFriend({ initiatorUid: uid, status: "blocked" });
|
||||
|
||||
//WHEN
|
||||
const friends = await UserDAL.getFriends(uid);
|
||||
|
||||
//THEN
|
||||
expect(friends).toEqual([
|
||||
{
|
||||
uid: friendOne.uid,
|
||||
name: "One",
|
||||
lastModified: 100,
|
||||
connectionId: friendOneRequest._id,
|
||||
// oxlint-disable-next-line no-non-null-assertion
|
||||
top15: friendOne.personalBests.time["15"]![0] as any,
|
||||
// oxlint-disable-next-line no-non-null-assertion
|
||||
top60: friendOne.personalBests.time["60"]![1] as any,
|
||||
badgeId: 42,
|
||||
banned: true,
|
||||
lbOptOut: true,
|
||||
isPremium: true,
|
||||
},
|
||||
{
|
||||
uid: friendTwo.uid,
|
||||
name: "Two",
|
||||
lastModified: 200,
|
||||
connectionId: friendTwoRequest._id,
|
||||
discordId: friendTwo.discordId,
|
||||
discordAvatar: friendTwo.discordAvatar,
|
||||
timeTyping: friendTwo.timeTyping,
|
||||
startedTests: friendTwo.startedTests,
|
||||
completedTests: friendTwo.completedTests,
|
||||
streak: {
|
||||
length: friendTwo.streak?.length,
|
||||
maxLength: friendTwo.streak?.maxLength,
|
||||
},
|
||||
xp: friendTwo.xp,
|
||||
isPremium: true,
|
||||
},
|
||||
{
|
||||
uid: friendThree.uid,
|
||||
name: "Three",
|
||||
lastModified: 300,
|
||||
connectionId: friendThreeRequest._id,
|
||||
},
|
||||
{
|
||||
uid: me.uid,
|
||||
name: "Me",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,6 +24,9 @@ beforeAll(async () => {
|
|||
}));
|
||||
|
||||
setupCommonMocks();
|
||||
|
||||
//we compare the time in mongodb to calculate premium status, so we have to use real time here
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
|
|
|||
24
backend/__tests__/__testData__/connections.ts
Normal file
24
backend/__tests__/__testData__/connections.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { ObjectId } from "mongodb";
|
||||
import * as ConnectionsDal from "../../src/dal/connections";
|
||||
|
||||
export async function createConnection(
|
||||
data: Partial<ConnectionsDal.DBConnection>,
|
||||
maxPerUser = 25
|
||||
): Promise<ConnectionsDal.DBConnection> {
|
||||
const result = await ConnectionsDal.create(
|
||||
{
|
||||
uid: data.initiatorUid ?? new ObjectId().toHexString(),
|
||||
name: data.initiatorName ?? "user" + new ObjectId().toHexString(),
|
||||
},
|
||||
{
|
||||
uid: data.receiverUid ?? new ObjectId().toHexString(),
|
||||
name: data.receiverName ?? "user" + new ObjectId().toHexString(),
|
||||
},
|
||||
maxPerUser
|
||||
);
|
||||
await ConnectionsDal.getCollection().updateOne(
|
||||
{ _id: result._id },
|
||||
{ $set: data }
|
||||
);
|
||||
return { ...result, ...data };
|
||||
}
|
||||
398
backend/__tests__/api/controllers/connections.spec.ts
Normal file
398
backend/__tests__/api/controllers/connections.spec.ts
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import request, { Test as SuperTest } from "supertest";
|
||||
import app from "../../../src/app";
|
||||
import { mockBearerAuthentication } from "../../__testData__/auth";
|
||||
import * as Configuration from "../../../src/init/configuration";
|
||||
import { ObjectId } from "mongodb";
|
||||
import _ from "lodash";
|
||||
import * as ConnectionsDal from "../../../src/dal/connections";
|
||||
import * as UserDal from "../../../src/dal/user";
|
||||
|
||||
const mockApp = request(app);
|
||||
const configuration = Configuration.getCachedConfiguration();
|
||||
const uid = new ObjectId().toHexString();
|
||||
const mockAuth = mockBearerAuthentication(uid);
|
||||
|
||||
describe("ConnectionsController", () => {
|
||||
beforeEach(async () => {
|
||||
await enableConnectionsEndpoints(true);
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1000);
|
||||
mockAuth.beforeEach();
|
||||
});
|
||||
|
||||
describe("get connections", () => {
|
||||
const getConnectionsMock = vi.spyOn(ConnectionsDal, "getConnections");
|
||||
|
||||
beforeEach(() => {
|
||||
getConnectionsMock.mockClear();
|
||||
});
|
||||
|
||||
it("should get for the current user", async () => {
|
||||
//GIVEN
|
||||
const friend: ConnectionsDal.DBConnection = {
|
||||
_id: new ObjectId(),
|
||||
lastModified: 42,
|
||||
initiatorUid: new ObjectId().toHexString(),
|
||||
initiatorName: "Bob",
|
||||
receiverUid: new ObjectId().toHexString(),
|
||||
receiverName: "Kevin",
|
||||
status: "pending",
|
||||
key: "key",
|
||||
};
|
||||
|
||||
getConnectionsMock.mockResolvedValue([friend]);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/connections")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body.data).toEqual([
|
||||
{ ...friend, _id: friend._id.toHexString(), key: undefined },
|
||||
]);
|
||||
expect(getConnectionsMock).toHaveBeenCalledWith({
|
||||
initiatorUid: uid,
|
||||
receiverUid: uid,
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter by status", async () => {
|
||||
//GIVEN
|
||||
getConnectionsMock.mockResolvedValue([]);
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.get("/connections")
|
||||
.query({ status: "accepted" })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(getConnectionsMock).toHaveBeenCalledWith({
|
||||
initiatorUid: uid,
|
||||
receiverUid: uid,
|
||||
status: ["accepted"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter by multiple status", async () => {
|
||||
//GIVEN
|
||||
getConnectionsMock.mockResolvedValue([]);
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.get("/connections")
|
||||
.query({ status: ["accepted", "blocked"] })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(getConnectionsMock).toHaveBeenCalledWith({
|
||||
initiatorUid: uid,
|
||||
receiverUid: uid,
|
||||
status: ["accepted", "blocked"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter by type incoming", async () => {
|
||||
//GIVEN
|
||||
getConnectionsMock.mockResolvedValue([]);
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.get("/connections")
|
||||
.query({ type: "incoming" })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(getConnectionsMock).toHaveBeenCalledWith({
|
||||
receiverUid: uid,
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter by type outgoing", async () => {
|
||||
//GIVEN
|
||||
getConnectionsMock.mockResolvedValue([]);
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.get("/connections")
|
||||
.query({ type: "outgoing" })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(getConnectionsMock).toHaveBeenCalledWith({
|
||||
initiatorUid: uid,
|
||||
});
|
||||
});
|
||||
|
||||
it("should filter by multiple types", async () => {
|
||||
//GIVEN
|
||||
getConnectionsMock.mockResolvedValue([]);
|
||||
|
||||
//WHEN
|
||||
await mockApp
|
||||
.get("/connections")
|
||||
.query({ type: ["incoming", "outgoing"] })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(getConnectionsMock).toHaveBeenCalledWith({
|
||||
initiatorUid: uid,
|
||||
receiverUid: uid,
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail if connections endpoints are disabled", async () => {
|
||||
await expectFailForDisabledEndpoint(
|
||||
mockApp.get("/connections").set("Authorization", `Bearer ${uid}`)
|
||||
);
|
||||
});
|
||||
it("should fail without authentication", async () => {
|
||||
await mockApp.get("/connections").expect(401);
|
||||
});
|
||||
it("should fail for unknown query parameter", async () => {
|
||||
const { body } = await mockApp
|
||||
.get("/connections")
|
||||
.query({ extra: "yes" })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
expect(body).toStrictEqual({
|
||||
message: "Invalid query schema",
|
||||
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("create connection", () => {
|
||||
const getUserByNameMock = vi.spyOn(UserDal, "getUserByName");
|
||||
const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser");
|
||||
const createUserMock = vi.spyOn(ConnectionsDal, "create");
|
||||
|
||||
beforeEach(() => {
|
||||
[getUserByNameMock, getPartialUserMock, createUserMock].forEach((it) =>
|
||||
it.mockClear()
|
||||
);
|
||||
});
|
||||
|
||||
it("should create", async () => {
|
||||
//GIVEN
|
||||
const me = { uid, name: "Bob" };
|
||||
const myFriend = { uid: new ObjectId().toHexString(), name: "Kevin" };
|
||||
getUserByNameMock.mockResolvedValue(myFriend as any);
|
||||
getPartialUserMock.mockResolvedValue(me as any);
|
||||
|
||||
const result: ConnectionsDal.DBConnection = {
|
||||
_id: new ObjectId(),
|
||||
lastModified: 42,
|
||||
initiatorUid: me.uid,
|
||||
initiatorName: me.name,
|
||||
receiverUid: myFriend.uid,
|
||||
receiverName: myFriend.name,
|
||||
key: "test",
|
||||
status: "pending",
|
||||
};
|
||||
createUserMock.mockResolvedValue(result);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/connections")
|
||||
.send({ receiverName: "Kevin" })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body.data).toEqual({
|
||||
_id: result._id.toHexString(),
|
||||
lastModified: 42,
|
||||
initiatorUid: me.uid,
|
||||
initiatorName: me.name,
|
||||
receiverUid: myFriend.uid,
|
||||
receiverName: myFriend.name,
|
||||
status: "pending",
|
||||
});
|
||||
|
||||
expect(getUserByNameMock).toHaveBeenCalledWith(
|
||||
"Kevin",
|
||||
"create connection"
|
||||
);
|
||||
expect(getPartialUserMock).toHaveBeenCalledWith(
|
||||
uid,
|
||||
"create connection",
|
||||
["uid", "name"]
|
||||
);
|
||||
expect(createUserMock).toHaveBeenCalledWith(me, myFriend, 100);
|
||||
});
|
||||
|
||||
it("should fail if user and receiver are the same", async () => {
|
||||
//GIVEN
|
||||
const me = { uid, name: "Bob" };
|
||||
|
||||
getUserByNameMock.mockResolvedValue(me as any);
|
||||
getPartialUserMock.mockResolvedValue(me as any);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/connections")
|
||||
.send({ receiverName: "Bob" })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(400);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("You cannot be your own friend, sorry.");
|
||||
});
|
||||
|
||||
it("should fail without mandatory properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/connections")
|
||||
.send({})
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [`"receiverName" Required`],
|
||||
});
|
||||
});
|
||||
it("should fail with extra properties", async () => {
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.post("/connections")
|
||||
.send({ receiverName: "1", extra: "value" })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
//THEN
|
||||
expect(body).toStrictEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: ["Unrecognized key(s) in object: 'extra'"],
|
||||
});
|
||||
});
|
||||
|
||||
it("should fail if connections endpoints are disabled", async () => {
|
||||
await expectFailForDisabledEndpoint(
|
||||
mockApp
|
||||
.post("/connections")
|
||||
.send({ receiverName: "1" })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
);
|
||||
});
|
||||
|
||||
it("should fail without authentication", async () => {
|
||||
await mockApp.post("/connections").expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("delete connection", () => {
|
||||
const deleteByIdMock = vi.spyOn(ConnectionsDal, "deleteById");
|
||||
|
||||
beforeEach(() => {
|
||||
deleteByIdMock.mockClear().mockResolvedValue();
|
||||
});
|
||||
|
||||
it("should delete by id", async () => {
|
||||
//WHEN
|
||||
await mockApp
|
||||
.delete("/connections/1")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(deleteByIdMock).toHaveBeenCalledWith(uid, "1");
|
||||
});
|
||||
it("should fail if connections endpoints are disabled", async () => {
|
||||
await expectFailForDisabledEndpoint(
|
||||
mockApp.delete("/connections/1").set("Authorization", `Bearer ${uid}`)
|
||||
);
|
||||
});
|
||||
|
||||
it("should fail without authentication", async () => {
|
||||
await mockApp.delete("/connections/1").expect(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("update connection", () => {
|
||||
const updateStatusMock = vi.spyOn(ConnectionsDal, "updateStatus");
|
||||
|
||||
beforeEach(() => {
|
||||
updateStatusMock.mockClear().mockResolvedValue();
|
||||
});
|
||||
|
||||
it("should accept", async () => {
|
||||
//WHEN
|
||||
await mockApp
|
||||
.patch("/connections/1")
|
||||
.send({ status: "accepted" })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(updateStatusMock).toHaveBeenCalledWith(uid, "1", "accepted");
|
||||
});
|
||||
it("should block", async () => {
|
||||
//WHEN
|
||||
await mockApp
|
||||
.patch("/connections/1")
|
||||
.send({ status: "blocked" })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(updateStatusMock).toHaveBeenCalledWith(uid, "1", "blocked");
|
||||
});
|
||||
|
||||
it("should fail for invalid status", async () => {
|
||||
const { body } = await mockApp
|
||||
.patch("/connections/1")
|
||||
.send({ status: "invalid" })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(422);
|
||||
|
||||
expect(body).toEqual({
|
||||
message: "Invalid request data schema",
|
||||
validationErrors: [
|
||||
`"status" Invalid enum value. Expected 'accepted' | 'blocked', received 'invalid'`,
|
||||
],
|
||||
});
|
||||
});
|
||||
it("should fail if connections endpoints are disabled", async () => {
|
||||
await expectFailForDisabledEndpoint(
|
||||
mockApp
|
||||
.patch("/connections/1")
|
||||
.send({ status: "accepted" })
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
);
|
||||
});
|
||||
|
||||
it("should fail without authentication", async () => {
|
||||
await mockApp
|
||||
.patch("/connections/1")
|
||||
.send({ status: "accepted" })
|
||||
.expect(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function enableConnectionsEndpoints(enabled: boolean): Promise<void> {
|
||||
const mockConfig = _.merge(await configuration, {
|
||||
connections: { enabled },
|
||||
});
|
||||
|
||||
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
|
||||
mockConfig
|
||||
);
|
||||
}
|
||||
async function expectFailForDisabledEndpoint(call: SuperTest): Promise<void> {
|
||||
await enableConnectionsEndpoints(false);
|
||||
const { body } = await call.expect(503);
|
||||
expect(body.message).toEqual("Connections are not available at this time.");
|
||||
}
|
||||
|
|
@ -36,7 +36,9 @@ import { MonkeyMail, UserStreak } from "@monkeytype/schemas/users";
|
|||
import MonkeyError, { isFirebaseError } from "../../../src/utils/error";
|
||||
import { LeaderboardEntry } from "@monkeytype/schemas/leaderboards";
|
||||
import * as WeeklyXpLeaderboard from "../../../src/services/weekly-xp-leaderboard";
|
||||
import * as ConnectionsDal from "../../../src/dal/connections";
|
||||
import { pb } from "../../__testData__/users";
|
||||
import { SuperTest } from "supertest";
|
||||
|
||||
const { mockApp, uid, mockAuth } = setup();
|
||||
const configuration = Configuration.getCachedConfiguration();
|
||||
|
|
@ -224,7 +226,7 @@ describe("user controller test", () => {
|
|||
userIsNameAvailableMock.mockClear();
|
||||
});
|
||||
|
||||
it("returns ok if name is available", async () => {
|
||||
it("returns available if name is available", async () => {
|
||||
//GIVEN
|
||||
userIsNameAvailableMock.mockResolvedValue(true);
|
||||
|
||||
|
|
@ -236,13 +238,13 @@ describe("user controller test", () => {
|
|||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Username available",
|
||||
data: null,
|
||||
message: "Check username",
|
||||
data: { available: true },
|
||||
});
|
||||
expect(userIsNameAvailableMock).toHaveBeenCalledWith("bob", "");
|
||||
});
|
||||
|
||||
it("returns 409 if name is not available", async () => {
|
||||
it("returns taken if name is not available", async () => {
|
||||
//GIVEN
|
||||
userIsNameAvailableMock.mockResolvedValue(false);
|
||||
|
||||
|
|
@ -250,10 +252,13 @@ describe("user controller test", () => {
|
|||
const { body } = await mockApp
|
||||
.get("/users/checkName/bob")
|
||||
//no authentication required
|
||||
.expect(409);
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body.message).toEqual("Username unavailable");
|
||||
expect(body).toEqual({
|
||||
message: "Check username",
|
||||
data: { available: false },
|
||||
});
|
||||
|
||||
expect(userIsNameAvailableMock).toHaveBeenCalledWith("bob", "");
|
||||
});
|
||||
|
|
@ -269,11 +274,17 @@ describe("user controller test", () => {
|
|||
|
||||
//THEN
|
||||
expect(body).toEqual({
|
||||
message: "Username available",
|
||||
data: null,
|
||||
message: "Check username",
|
||||
data: { available: true },
|
||||
});
|
||||
expect(userIsNameAvailableMock).toHaveBeenCalledWith("bob", uid);
|
||||
});
|
||||
it("returns 422 if username contains profanity", async () => {
|
||||
await mockApp
|
||||
.get("/users/checkName/newMiodec")
|
||||
//no authentication required
|
||||
.expect(422);
|
||||
});
|
||||
});
|
||||
describe("sendVerificationEmail", () => {
|
||||
const adminGetUserMock = vi.fn();
|
||||
|
|
@ -624,6 +635,7 @@ describe("user controller test", () => {
|
|||
"purgeUserFromXpLeaderboards"
|
||||
);
|
||||
const blocklistAddMock = vi.spyOn(BlocklistDal, "add");
|
||||
const connectionsDeletebyUidMock = vi.spyOn(ConnectionsDal, "deleteByUid");
|
||||
const logsDeleteUserMock = vi.spyOn(LogDal, "deleteUserLogs");
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -636,6 +648,7 @@ describe("user controller test", () => {
|
|||
deleteConfigMock,
|
||||
purgeUserFromDailyLeaderboardsMock,
|
||||
purgeUserFromXpLeaderboardsMock,
|
||||
connectionsDeletebyUidMock,
|
||||
logsDeleteUserMock,
|
||||
].forEach((it) => it.mockResolvedValue(undefined));
|
||||
|
||||
|
|
@ -654,6 +667,7 @@ describe("user controller test", () => {
|
|||
deleteAllPresetsMock,
|
||||
purgeUserFromDailyLeaderboardsMock,
|
||||
purgeUserFromXpLeaderboardsMock,
|
||||
connectionsDeletebyUidMock,
|
||||
logsDeleteUserMock,
|
||||
].forEach((it) => it.mockClear());
|
||||
});
|
||||
|
|
@ -684,6 +698,7 @@ describe("user controller test", () => {
|
|||
expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid);
|
||||
expect(deleteConfigMock).toHaveBeenCalledWith(uid);
|
||||
expect(deleteAllResultMock).toHaveBeenCalledWith(uid);
|
||||
expect(connectionsDeletebyUidMock).toHaveBeenCalledWith(uid);
|
||||
expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith(
|
||||
uid,
|
||||
(await configuration).dailyLeaderboards
|
||||
|
|
@ -719,6 +734,7 @@ describe("user controller test", () => {
|
|||
expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid);
|
||||
expect(deleteConfigMock).toHaveBeenCalledWith(uid);
|
||||
expect(deleteAllResultMock).toHaveBeenCalledWith(uid);
|
||||
expect(connectionsDeletebyUidMock).toHaveBeenCalledWith(uid);
|
||||
expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith(
|
||||
uid,
|
||||
(await configuration).dailyLeaderboards
|
||||
|
|
@ -749,6 +765,7 @@ describe("user controller test", () => {
|
|||
expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid);
|
||||
expect(deleteConfigMock).toHaveBeenCalledWith(uid);
|
||||
expect(deleteAllResultMock).toHaveBeenCalledWith(uid);
|
||||
expect(connectionsDeletebyUidMock).toHaveBeenCalledWith(uid);
|
||||
expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith(
|
||||
uid,
|
||||
(await configuration).dailyLeaderboards
|
||||
|
|
@ -778,6 +795,7 @@ describe("user controller test", () => {
|
|||
expect(deleteAllPresetsMock).not.toHaveBeenCalledWith(uid);
|
||||
expect(deleteConfigMock).not.toHaveBeenCalledWith(uid);
|
||||
expect(deleteAllResultMock).not.toHaveBeenCalledWith(uid);
|
||||
expect(connectionsDeletebyUidMock).not.toHaveBeenCalledWith(uid);
|
||||
expect(purgeUserFromDailyLeaderboardsMock).not.toHaveBeenCalledWith(
|
||||
uid,
|
||||
(await configuration).dailyLeaderboards
|
||||
|
|
@ -818,6 +836,7 @@ describe("user controller test", () => {
|
|||
expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid);
|
||||
expect(deleteConfigMock).toHaveBeenCalledWith(uid);
|
||||
expect(deleteAllResultMock).toHaveBeenCalledWith(uid);
|
||||
expect(connectionsDeletebyUidMock).toHaveBeenCalledWith(uid);
|
||||
expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith(
|
||||
uid,
|
||||
(await configuration).dailyLeaderboards
|
||||
|
|
@ -858,6 +877,7 @@ describe("user controller test", () => {
|
|||
expect(deleteAllPresetsMock).toHaveBeenCalledWith(uid);
|
||||
expect(deleteConfigMock).toHaveBeenCalledWith(uid);
|
||||
expect(deleteAllResultMock).toHaveBeenCalledWith(uid);
|
||||
expect(connectionsDeletebyUidMock).toHaveBeenCalledWith(uid);
|
||||
expect(purgeUserFromDailyLeaderboardsMock).toHaveBeenCalledWith(
|
||||
uid,
|
||||
(await configuration).dailyLeaderboards
|
||||
|
|
@ -978,6 +998,7 @@ describe("user controller test", () => {
|
|||
const blocklistContainsMock = vi.spyOn(BlocklistDal, "contains");
|
||||
const getPartialUserMock = vi.spyOn(UserDal, "getPartialUser");
|
||||
const updateNameMock = vi.spyOn(UserDal, "updateName");
|
||||
const connectionsUpdateNameMock = vi.spyOn(ConnectionsDal, "updateName");
|
||||
const addImportantLogMock = vi.spyOn(LogDal, "addImportantLog");
|
||||
|
||||
beforeEach(() => {
|
||||
|
|
@ -985,6 +1006,7 @@ describe("user controller test", () => {
|
|||
blocklistContainsMock,
|
||||
getPartialUserMock,
|
||||
updateNameMock,
|
||||
connectionsUpdateNameMock,
|
||||
addImportantLogMock,
|
||||
].forEach((it) => {
|
||||
it.mockClear().mockResolvedValue(null as never);
|
||||
|
|
@ -1016,6 +1038,7 @@ describe("user controller test", () => {
|
|||
"changed name from Bob to newName",
|
||||
uid
|
||||
);
|
||||
expect(connectionsUpdateNameMock).toHaveBeenCalledWith(uid, "newName");
|
||||
});
|
||||
|
||||
it("should fail if username is blocked", async () => {
|
||||
|
|
@ -1032,6 +1055,7 @@ describe("user controller test", () => {
|
|||
//THEN
|
||||
expect(body.message).toEqual("Username blocked");
|
||||
expect(updateNameMock).not.toHaveBeenCalled();
|
||||
expect(connectionsUpdateNameMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should fail for banned users", async () => {
|
||||
|
|
@ -3875,6 +3899,62 @@ describe("user controller test", () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
describe("get friends", () => {
|
||||
const getFriendsMock = vi.spyOn(UserDal, "getFriends");
|
||||
|
||||
beforeEach(() => {
|
||||
enableConnectionsEndpoints(true);
|
||||
getFriendsMock.mockClear();
|
||||
});
|
||||
|
||||
it("gets with premium enabled", async () => {
|
||||
//GIVEN
|
||||
enablePremiumFeatures(true);
|
||||
const friend: UserDal.DBFriend = {
|
||||
name: "Bob",
|
||||
isPremium: true,
|
||||
} as any;
|
||||
getFriendsMock.mockResolvedValue([friend]);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/users/friends")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body.data).toEqual([{ name: "Bob", isPremium: true }]);
|
||||
});
|
||||
|
||||
it("gets with premium disabled", async () => {
|
||||
//GIVEN
|
||||
enablePremiumFeatures(false);
|
||||
const friend: UserDal.DBFriend = {
|
||||
name: "Bob",
|
||||
isPremium: true,
|
||||
} as any;
|
||||
getFriendsMock.mockResolvedValue([friend]);
|
||||
|
||||
//WHEN
|
||||
const { body } = await mockApp
|
||||
.get("/users/friends")
|
||||
.set("Authorization", `Bearer ${uid}`)
|
||||
.expect(200);
|
||||
|
||||
//THEN
|
||||
expect(body.data).toEqual([{ name: "Bob" }]);
|
||||
});
|
||||
|
||||
it("should fail if friends endpoints are disabled", async () => {
|
||||
await expectFailForDisabledEndpoint(
|
||||
mockApp.get("/users/friends").set("Authorization", `Bearer ${uid}`)
|
||||
);
|
||||
});
|
||||
|
||||
it("should fail without authentication", async () => {
|
||||
await mockApp.get("/users/friends").expect(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function fillYearWithDay(days: number): number[] {
|
||||
|
|
@ -3974,3 +4054,18 @@ async function enableReporting(enabled: boolean): Promise<void> {
|
|||
mockConfig
|
||||
);
|
||||
}
|
||||
|
||||
async function enableConnectionsEndpoints(enabled: boolean): Promise<void> {
|
||||
const mockConfig = _.merge(await configuration, {
|
||||
connections: { enabled },
|
||||
});
|
||||
|
||||
vi.spyOn(Configuration, "getCachedConfiguration").mockResolvedValue(
|
||||
mockConfig
|
||||
);
|
||||
}
|
||||
async function expectFailForDisabledEndpoint(call: SuperTest): Promise<void> {
|
||||
await enableConnectionsEndpoints(false);
|
||||
const { body } = await call.expect(503);
|
||||
expect(body.message).toEqual("Connections are not available at this time.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,6 +99,12 @@ export function getOpenApi(): OpenAPIObject {
|
|||
description: "All-time and daily leaderboards of the fastest typers.",
|
||||
"x-displayName": "Leaderboards",
|
||||
},
|
||||
{
|
||||
name: "connections",
|
||||
description: "Connections between users.",
|
||||
"x-displayName": "Connections",
|
||||
"x-public": "no",
|
||||
},
|
||||
{
|
||||
name: "psas",
|
||||
description: "Public service announcements.",
|
||||
|
|
|
|||
85
backend/src/api/controllers/connections.ts
Normal file
85
backend/src/api/controllers/connections.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import {
|
||||
CreateConnectionRequest,
|
||||
CreateConnectionResponse,
|
||||
GetConnectionsQuery,
|
||||
GetConnectionsResponse,
|
||||
IdPathParams,
|
||||
UpdateConnectionRequest,
|
||||
} from "@monkeytype/contracts/connections";
|
||||
import { MonkeyRequest } from "../types";
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import * as ConnectionsDal from "../../dal/connections";
|
||||
import * as UserDal from "../../dal/user";
|
||||
import { replaceObjectId } from "../../utils/misc";
|
||||
import MonkeyError from "../../utils/error";
|
||||
import { omit } from "lodash";
|
||||
import { Connection } from "@monkeytype/schemas/connections";
|
||||
|
||||
function convert(db: ConnectionsDal.DBConnection): Connection {
|
||||
return replaceObjectId(omit(db, "key"));
|
||||
}
|
||||
export async function getConnections(
|
||||
req: MonkeyRequest<GetConnectionsQuery>
|
||||
): Promise<GetConnectionsResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const { status, type } = req.query;
|
||||
|
||||
const results = await ConnectionsDal.getConnections({
|
||||
initiatorUid:
|
||||
type === undefined || type.includes("outgoing") ? uid : undefined,
|
||||
receiverUid:
|
||||
type === undefined || type?.includes("incoming") ? uid : undefined,
|
||||
status: status,
|
||||
});
|
||||
|
||||
return new MonkeyResponse("Connections retrieved", results.map(convert));
|
||||
}
|
||||
|
||||
export async function createConnection(
|
||||
req: MonkeyRequest<undefined, CreateConnectionRequest>
|
||||
): Promise<CreateConnectionResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const { receiverName } = req.body;
|
||||
const { maxPerUser } = req.ctx.configuration.connections;
|
||||
|
||||
const receiver = await UserDal.getUserByName(
|
||||
receiverName,
|
||||
"create connection"
|
||||
);
|
||||
|
||||
if (uid === receiver.uid) {
|
||||
throw new MonkeyError(400, "You cannot be your own friend, sorry.");
|
||||
}
|
||||
|
||||
const initiator = await UserDal.getPartialUser(uid, "create connection", [
|
||||
"uid",
|
||||
"name",
|
||||
]);
|
||||
|
||||
const result = await ConnectionsDal.create(initiator, receiver, maxPerUser);
|
||||
|
||||
return new MonkeyResponse("Connection created", convert(result));
|
||||
}
|
||||
|
||||
export async function deleteConnection(
|
||||
req: MonkeyRequest<undefined, undefined, IdPathParams>
|
||||
): Promise<MonkeyResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const { id } = req.params;
|
||||
|
||||
await ConnectionsDal.deleteById(uid, id);
|
||||
|
||||
return new MonkeyResponse("Connection deleted", null);
|
||||
}
|
||||
|
||||
export async function updateConnection(
|
||||
req: MonkeyRequest<undefined, UpdateConnectionRequest, IdPathParams>
|
||||
): Promise<MonkeyResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const { id } = req.params;
|
||||
const { status } = req.body;
|
||||
|
||||
await ConnectionsDal.updateStatus(uid, id, status);
|
||||
|
||||
return new MonkeyResponse("Connection updated", null);
|
||||
}
|
||||
|
|
@ -51,6 +51,7 @@ import {
|
|||
AddTagRequest,
|
||||
AddTagResponse,
|
||||
CheckNamePathParameters,
|
||||
CheckNameResponse,
|
||||
CreateUserRequest,
|
||||
DeleteCustomThemeRequest,
|
||||
EditCustomThemeRequst,
|
||||
|
|
@ -60,6 +61,7 @@ import {
|
|||
GetCustomThemesResponse,
|
||||
GetDiscordOauthLinkResponse,
|
||||
GetFavoriteQuotesResponse,
|
||||
GetFriendsResponse,
|
||||
GetPersonalBestsQuery,
|
||||
GetPersonalBestsResponse,
|
||||
GetProfilePathParams,
|
||||
|
|
@ -89,6 +91,7 @@ import {
|
|||
import { MILLISECONDS_IN_DAY } from "@monkeytype/util/date-and-time";
|
||||
import { MonkeyRequest } from "../types";
|
||||
import { tryCatch } from "@monkeytype/util/trycatch";
|
||||
import * as ConnectionsDal from "../../dal/connections";
|
||||
|
||||
async function verifyCaptcha(captcha: string): Promise<void> {
|
||||
const { data: verified, error } = await tryCatch(verify(captcha));
|
||||
|
|
@ -292,6 +295,7 @@ export async function deleteUser(req: MonkeyRequest): Promise<MonkeyResponse> {
|
|||
uid,
|
||||
req.ctx.configuration.leaderboards.weeklyXp
|
||||
),
|
||||
ConnectionsDal.deleteByUid(uid),
|
||||
]);
|
||||
|
||||
try {
|
||||
|
|
@ -382,6 +386,8 @@ export async function updateName(
|
|||
}
|
||||
|
||||
await UserDAL.updateName(uid, name, user.name);
|
||||
|
||||
await ConnectionsDal.updateName(uid, name);
|
||||
void addImportantLog(
|
||||
"user_name_updated",
|
||||
`changed name from ${user.name} to ${name}`,
|
||||
|
|
@ -425,16 +431,15 @@ export async function optOutOfLeaderboards(
|
|||
|
||||
export async function checkName(
|
||||
req: MonkeyRequest<undefined, undefined, CheckNamePathParameters>
|
||||
): Promise<MonkeyResponse> {
|
||||
): Promise<CheckNameResponse> {
|
||||
const { name } = req.params;
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
|
||||
const available = await UserDAL.isNameAvailable(name, uid);
|
||||
if (!available) {
|
||||
throw new MonkeyError(409, "Username unavailable");
|
||||
}
|
||||
|
||||
return new MonkeyResponse("Username available", null);
|
||||
return new MonkeyResponse("Check username", {
|
||||
available,
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateEmail(
|
||||
|
|
@ -1261,3 +1266,19 @@ export async function getStreak(
|
|||
|
||||
return new MonkeyResponse("Streak data retrieved", user.streak ?? null);
|
||||
}
|
||||
|
||||
export async function getFriends(
|
||||
req: MonkeyRequest
|
||||
): Promise<GetFriendsResponse> {
|
||||
const { uid } = req.ctx.decodedToken;
|
||||
const premiumEnabled = req.ctx.configuration.users.premium.enabled;
|
||||
const data = await UserDAL.getFriends(uid);
|
||||
|
||||
if (!premiumEnabled) {
|
||||
for (const friend of data) {
|
||||
delete friend.isPremium;
|
||||
}
|
||||
}
|
||||
|
||||
return new MonkeyResponse("Friends retrieved", data);
|
||||
}
|
||||
|
|
|
|||
25
backend/src/api/routes/connections.ts
Normal file
25
backend/src/api/routes/connections.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { connectionsContract } from "@monkeytype/contracts/connections";
|
||||
import { initServer } from "@ts-rest/express";
|
||||
import { callController } from "../ts-rest-adapter";
|
||||
|
||||
import * as ConnectionsController from "../controllers/connections";
|
||||
|
||||
const s = initServer();
|
||||
export default s.router(connectionsContract, {
|
||||
get: {
|
||||
handler: async (r) =>
|
||||
callController(ConnectionsController.getConnections)(r),
|
||||
},
|
||||
create: {
|
||||
handler: async (r) =>
|
||||
callController(ConnectionsController.createConnection)(r),
|
||||
},
|
||||
delete: {
|
||||
handler: async (r) =>
|
||||
callController(ConnectionsController.deleteConnection)(r),
|
||||
},
|
||||
update: {
|
||||
handler: async (r) =>
|
||||
callController(ConnectionsController.updateConnection)(r),
|
||||
},
|
||||
});
|
||||
|
|
@ -16,6 +16,7 @@ import configs from "./configs";
|
|||
import configuration from "./configuration";
|
||||
import { version } from "../../version";
|
||||
import leaderboards from "./leaderboards";
|
||||
import connections from "./connections";
|
||||
import addSwaggerMiddlewares from "./swagger";
|
||||
import { MonkeyResponse } from "../../utils/monkey-response";
|
||||
import {
|
||||
|
|
@ -61,6 +62,7 @@ const router = s.router(contract, {
|
|||
users,
|
||||
quotes,
|
||||
webhooks,
|
||||
connections,
|
||||
});
|
||||
|
||||
export function addApiRoutes(app: Application): void {
|
||||
|
|
|
|||
|
|
@ -137,4 +137,7 @@ export default s.router(usersContract, {
|
|||
getStreak: {
|
||||
handler: async (r) => callController(UserController.getStreak)(r),
|
||||
},
|
||||
getFriends: {
|
||||
handler: async (r) => callController(UserController.getFriends)(r),
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ export const BASE_CONFIGURATION: Configuration = {
|
|||
xpRewardBrackets: [],
|
||||
},
|
||||
},
|
||||
connections: { enabled: false, maxPerUser: 100 },
|
||||
};
|
||||
|
||||
type BaseSchema = {
|
||||
|
|
@ -603,5 +604,16 @@ export const CONFIGURATION_FORM_SCHEMA: ObjectSchema<Configuration> = {
|
|||
},
|
||||
},
|
||||
},
|
||||
connections: {
|
||||
type: "object",
|
||||
label: "Connections",
|
||||
fields: {
|
||||
enabled: { type: "boolean", label: "Enabled" },
|
||||
maxPerUser: {
|
||||
type: "number",
|
||||
label: "Max Connections per user",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
202
backend/src/dal/connections.ts
Normal file
202
backend/src/dal/connections.ts
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
import { Collection, Filter, ObjectId } from "mongodb";
|
||||
import * as db from "../init/db";
|
||||
import { Connection, ConnectionStatus } from "@monkeytype/schemas/connections";
|
||||
import MonkeyError from "../utils/error";
|
||||
import { WithObjectId } from "../utils/misc";
|
||||
|
||||
export type DBConnection = WithObjectId<
|
||||
Connection & {
|
||||
key: string; //sorted uid
|
||||
}
|
||||
>;
|
||||
|
||||
export const getCollection = (): Collection<DBConnection> =>
|
||||
db.collection("connections");
|
||||
|
||||
export async function getConnections(options: {
|
||||
initiatorUid?: string;
|
||||
receiverUid?: string;
|
||||
status?: ConnectionStatus[];
|
||||
}): Promise<DBConnection[]> {
|
||||
const { initiatorUid, receiverUid, status } = options;
|
||||
|
||||
if (initiatorUid === undefined && receiverUid === undefined)
|
||||
throw new Error("Missing filter");
|
||||
|
||||
let filter: Filter<DBConnection> = { $or: [] };
|
||||
|
||||
if (initiatorUid !== undefined) {
|
||||
filter.$or?.push({ initiatorUid });
|
||||
}
|
||||
|
||||
if (receiverUid !== undefined) {
|
||||
filter.$or?.push({ receiverUid });
|
||||
}
|
||||
|
||||
if (status !== undefined) {
|
||||
filter.status = { $in: status };
|
||||
}
|
||||
|
||||
return await getCollection().find(filter).toArray();
|
||||
}
|
||||
|
||||
export async function create(
|
||||
initiator: { uid: string; name: string },
|
||||
receiver: { uid: string; name: string },
|
||||
maxPerUser: number
|
||||
): Promise<DBConnection> {
|
||||
const count = await getCollection().countDocuments({
|
||||
initiatorUid: initiator.uid,
|
||||
});
|
||||
|
||||
if (count >= maxPerUser) {
|
||||
throw new MonkeyError(
|
||||
409,
|
||||
"Maximum number of connections reached",
|
||||
"create connection request"
|
||||
);
|
||||
}
|
||||
const key = getKey(initiator.uid, receiver.uid);
|
||||
try {
|
||||
const created: DBConnection = {
|
||||
_id: new ObjectId(),
|
||||
key,
|
||||
initiatorUid: initiator.uid,
|
||||
initiatorName: initiator.name,
|
||||
receiverUid: receiver.uid,
|
||||
receiverName: receiver.name,
|
||||
lastModified: Date.now(),
|
||||
status: "pending",
|
||||
};
|
||||
|
||||
await getCollection().insertOne(created);
|
||||
|
||||
return created;
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
if (e.name === "MongoServerError" && e.code === 11000) {
|
||||
const existing = await getCollection().findOne(
|
||||
{ key },
|
||||
{ projection: { status: 1 } }
|
||||
);
|
||||
|
||||
let message = "";
|
||||
|
||||
if (existing?.status === "accepted") {
|
||||
message = "Connection already exists";
|
||||
} else if (existing?.status === "pending") {
|
||||
message = "Connection request already sent";
|
||||
} else if (existing?.status === "blocked") {
|
||||
if (existing.initiatorUid === initiator.uid) {
|
||||
message = "Connection blocked by initiator";
|
||||
} else {
|
||||
message = "Connection blocked by receiver";
|
||||
}
|
||||
} else {
|
||||
message = "Duplicate connection";
|
||||
}
|
||||
|
||||
throw new MonkeyError(409, message);
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*Update the status of a connection by id
|
||||
* @param receiverUid
|
||||
* @param id
|
||||
* @param status
|
||||
* @throws MonkeyError if the connection id is unknown or the recieverUid does not match
|
||||
*/
|
||||
export async function updateStatus(
|
||||
receiverUid: string,
|
||||
id: string,
|
||||
status: ConnectionStatus
|
||||
): Promise<void> {
|
||||
const updateResult = await getCollection().updateOne(
|
||||
{
|
||||
_id: new ObjectId(id),
|
||||
receiverUid,
|
||||
},
|
||||
{ $set: { status, lastModified: Date.now() } }
|
||||
);
|
||||
|
||||
if (updateResult.matchedCount === 0) {
|
||||
throw new MonkeyError(404, "No permission or connection not found");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* delete a connection by the id.
|
||||
* @param uid
|
||||
* @param id
|
||||
* @throws MonkeyError if the connection id is unknown or uid does not match
|
||||
*/
|
||||
export async function deleteById(uid: string, id: string): Promise<void> {
|
||||
const deletionResult = await getCollection().deleteOne({
|
||||
$and: [
|
||||
{
|
||||
_id: new ObjectId(id),
|
||||
},
|
||||
{
|
||||
$or: [
|
||||
{ receiverUid: uid },
|
||||
{ status: { $in: ["accepted", "pending"] }, initiatorUid: uid },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (deletionResult.deletedCount === 0) {
|
||||
throw new MonkeyError(404, "No permission or connection not found");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update all connections for the uid (initiator or receiver) with the given name.
|
||||
* @param uid
|
||||
* @param newName
|
||||
*/
|
||||
export async function updateName(uid: string, newName: string): Promise<void> {
|
||||
await getCollection().bulkWrite([
|
||||
{
|
||||
updateMany: {
|
||||
filter: { initiatorUid: uid },
|
||||
update: { $set: { initiatorName: newName } },
|
||||
},
|
||||
},
|
||||
{
|
||||
updateMany: {
|
||||
filter: { receiverUid: uid },
|
||||
update: { $set: { receiverName: newName } },
|
||||
},
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all connections containing the uid as initiatorUid or receiverUid
|
||||
* @param uid
|
||||
*/
|
||||
export async function deleteByUid(uid: string): Promise<void> {
|
||||
await getCollection().deleteMany({
|
||||
$or: [{ initiatorUid: uid }, { receiverUid: uid }],
|
||||
});
|
||||
}
|
||||
|
||||
function getKey(initiatorUid: string, receiverUid: string): string {
|
||||
const ids = [initiatorUid, receiverUid];
|
||||
ids.sort();
|
||||
return ids.join("/");
|
||||
}
|
||||
|
||||
export async function createIndicies(): Promise<void> {
|
||||
//index used for search
|
||||
await getCollection().createIndex({ initiatorUid: 1, status: 1 });
|
||||
await getCollection().createIndex({ receiverUid: 1, status: 1 });
|
||||
|
||||
//make sure there is only one connection for each initiatorr/receiver
|
||||
await getCollection().createIndex({ key: 1 }, { unique: true });
|
||||
}
|
||||
|
|
@ -26,6 +26,7 @@ import {
|
|||
UserTag,
|
||||
User,
|
||||
CountByYearAndDay,
|
||||
Friend,
|
||||
} from "@monkeytype/schemas/users";
|
||||
import { Mode, Mode2, PersonalBest } from "@monkeytype/schemas/shared";
|
||||
import { addImportantLog } from "./logs";
|
||||
|
|
@ -33,6 +34,7 @@ import { Result as ResultType } from "@monkeytype/schemas/results";
|
|||
import { Configuration } from "@monkeytype/schemas/configuration";
|
||||
import { isToday, isYesterday } from "@monkeytype/util/date-and-time";
|
||||
import GeorgeQueue from "../queues/george-queue";
|
||||
import { getCollection as getConnectionCollection } from "./connections";
|
||||
|
||||
export type DBUserTag = WithObjectId<UserTag>;
|
||||
|
||||
|
|
@ -65,6 +67,8 @@ const SECONDS_PER_HOUR = 3600;
|
|||
|
||||
type Result = Omit<ResultType<Mode>, "_id" | "name">;
|
||||
|
||||
export type DBFriend = Friend;
|
||||
|
||||
// Export for use in tests
|
||||
export const getUsersCollection = (): Collection<DBUser> =>
|
||||
db.collection<DBUser>("users");
|
||||
|
|
@ -1222,3 +1226,212 @@ async function updateUser(
|
|||
error.stack
|
||||
);
|
||||
}
|
||||
|
||||
export async function getFriends(uid: string): Promise<DBFriend[]> {
|
||||
return (await getConnectionCollection()
|
||||
.aggregate([
|
||||
{
|
||||
$match: {
|
||||
//uid is friend or initiator
|
||||
$and: [
|
||||
{
|
||||
$or: [{ initiatorUid: uid }, { receiverUid: uid }],
|
||||
status: "accepted",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
receiverUid: true,
|
||||
initiatorUid: true,
|
||||
lastModified: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
$addFields: {
|
||||
//pick the other user, not uid
|
||||
uid: {
|
||||
$cond: {
|
||||
if: { $eq: ["$receiverUid", uid] },
|
||||
// oxlint-disable-next-line no-thenable
|
||||
then: "$initiatorUid",
|
||||
else: "$receiverUid",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
// we want to fetch the data for our uid as well, add it to the list of documents
|
||||
// workaround for missing unionWith + $documents in mongodb 5.0
|
||||
{
|
||||
$group: {
|
||||
_id: null,
|
||||
data: {
|
||||
$push: {
|
||||
uid: "$uid",
|
||||
lastModified: "$lastModified",
|
||||
connectionId: "$_id",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
data: {
|
||||
$concatArrays: ["$data", [{ uid }]],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$unwind: "$data",
|
||||
},
|
||||
|
||||
/* end of workaround, this is the replacement for >= 5.1
|
||||
|
||||
{ $addFields: { connectionId: "$_id" } },
|
||||
{ $project: { uid: true, lastModified: true, connectionId: true } },
|
||||
{
|
||||
$unionWith: {
|
||||
pipeline: [{ $documents: [{ uid }] }],
|
||||
},
|
||||
},
|
||||
*/
|
||||
|
||||
{
|
||||
$lookup: {
|
||||
/* query users to get the friend data */
|
||||
from: "users",
|
||||
localField: "data.uid", //just uid if we remove the workaround above
|
||||
foreignField: "uid",
|
||||
as: "result",
|
||||
let: {
|
||||
lastModified: "$data.lastModified", //just $lastModified if we remove the workaround above
|
||||
connectionId: "$data.connectionId", //just $connectionId if we remove the workaround above
|
||||
},
|
||||
pipeline: [
|
||||
{
|
||||
$project: {
|
||||
_id: false,
|
||||
uid: true,
|
||||
connectionId: true,
|
||||
name: true,
|
||||
discordId: true,
|
||||
discordAvatar: true,
|
||||
startedTests: true,
|
||||
completedTests: true,
|
||||
timeTyping: true,
|
||||
xp: true,
|
||||
"streak.length": true,
|
||||
"streak.maxLength": true,
|
||||
personalBests: true,
|
||||
"inventory.badges": true,
|
||||
"premium.expirationTimestamp": true,
|
||||
banned: 1,
|
||||
lbOptOut: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
$addFields: {
|
||||
lastModified: "$$lastModified",
|
||||
connectionId: "$$connectionId",
|
||||
top15: {
|
||||
$reduce: {
|
||||
//find highest wpm from time 15 PBs
|
||||
input: "$personalBests.time.15",
|
||||
initialValue: {},
|
||||
in: {
|
||||
$cond: [
|
||||
{ $gte: ["$$this.wpm", "$$value.wpm"] },
|
||||
"$$this",
|
||||
"$$value",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
top60: {
|
||||
$reduce: {
|
||||
//find highest wpm from time 60 PBs
|
||||
input: "$personalBests.time.60",
|
||||
initialValue: {},
|
||||
in: {
|
||||
$cond: [
|
||||
{ $gte: ["$$this.wpm", "$$value.wpm"] },
|
||||
"$$this",
|
||||
"$$value",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
badgeId: {
|
||||
$ifNull: [
|
||||
{
|
||||
$first: {
|
||||
$map: {
|
||||
input: {
|
||||
$filter: {
|
||||
input: "$inventory.badges",
|
||||
as: "badge",
|
||||
cond: { $eq: ["$$badge.selected", true] },
|
||||
},
|
||||
},
|
||||
as: "selectedBadge",
|
||||
in: "$$selectedBadge.id",
|
||||
},
|
||||
},
|
||||
},
|
||||
"$$REMOVE",
|
||||
],
|
||||
},
|
||||
isPremium: {
|
||||
$cond: {
|
||||
if: {
|
||||
$or: [
|
||||
{ $eq: ["$premium.expirationTimestamp", -1] },
|
||||
{
|
||||
$gt: [
|
||||
"$premium.expirationTimestamp",
|
||||
{ $toLong: "$$NOW" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
// oxlint-disable-next-line no-thenable
|
||||
then: true,
|
||||
else: "$$REMOVE",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$addFields: {
|
||||
//remove nulls
|
||||
top15: { $ifNull: ["$top15", "$$REMOVE"] },
|
||||
top60: { $ifNull: ["$top60", "$$REMOVE"] },
|
||||
badgeId: { $ifNull: ["$badgeId", "$$REMOVE"] },
|
||||
lastModified: "$lastModified",
|
||||
},
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
personalBests: false,
|
||||
inventory: false,
|
||||
premium: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
$replaceRoot: {
|
||||
newRoot: {
|
||||
$cond: [
|
||||
{ $gt: [{ $size: "$result" }, 0] },
|
||||
{ $first: "$result" },
|
||||
{}, // empty document fallback, this can happen if the user is not present
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
])
|
||||
.toArray()) as DBFriend[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import * as EmailClient from "./init/email-client";
|
|||
import { init as initFirebaseAdmin } from "./init/firebase-admin";
|
||||
import { createIndicies as leaderboardDbSetup } from "./dal/leaderboards";
|
||||
import { createIndicies as blocklistDbSetup } from "./dal/blocklist";
|
||||
import { createIndicies as connectionsDbSetup } from "./dal/connections";
|
||||
import { getErrorMessage } from "./utils/error";
|
||||
|
||||
async function bootServer(port: number): Promise<Server> {
|
||||
|
|
@ -76,6 +77,9 @@ async function bootServer(port: number): Promise<Server> {
|
|||
Logger.info("Setting up blocklist indicies...");
|
||||
await blocklistDbSetup();
|
||||
|
||||
Logger.info("Setting up connections indicies...");
|
||||
await connectionsDbSetup();
|
||||
|
||||
recordServerVersion(version);
|
||||
} catch (error) {
|
||||
Logger.error("Failed to boot server");
|
||||
|
|
|
|||
|
|
@ -116,7 +116,7 @@
|
|||
<div class="icon">
|
||||
<i class="fas fa-fw fa-bell"></i>
|
||||
</div>
|
||||
<div class="notificationBubble hidden">5</div>
|
||||
<div class="notificationBubble hidden"></div>
|
||||
</button>
|
||||
<a
|
||||
class="textButton view-login"
|
||||
|
|
@ -137,6 +137,7 @@
|
|||
<i class="fas fa-fw fa-spin fa-circle-notch"></i>
|
||||
</div>
|
||||
<div class="avatar"></div>
|
||||
<div class="notificationBubble hidden"></div>
|
||||
<div class="text"></div>
|
||||
<div class="levelAndBar">
|
||||
<div class="level" data-balloon-pos="up">1</div>
|
||||
|
|
@ -157,6 +158,16 @@
|
|||
<i class="fas fa-fw fa-chart-line"></i>
|
||||
User stats
|
||||
</a>
|
||||
<a
|
||||
href="/friends"
|
||||
class="button goToFriends"
|
||||
onclick="this.blur();"
|
||||
router-link
|
||||
>
|
||||
<i class="fas fa-fw fa-user-friends"></i>
|
||||
Friends
|
||||
<span class="notificationBubble hidden">99</span>
|
||||
</a>
|
||||
<a
|
||||
href="/404"
|
||||
class="button goToProfile"
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@
|
|||
<i class="fas fa-key"></i>
|
||||
authentication
|
||||
</button>
|
||||
<button class="text" data-tab="blockedUsers">
|
||||
<i class="fas fa-user-shield"></i>
|
||||
blocked users
|
||||
</button>
|
||||
<button class="text" data-tab="apeKeys">
|
||||
<i class="fas fa-code"></i>
|
||||
ape keys
|
||||
|
|
@ -241,6 +245,27 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab hidden" data-tab="blockedUsers">
|
||||
<div class="section blockedUsers">
|
||||
<div class="title">
|
||||
<i class="fas fa-user-shield"></i>
|
||||
<span>blocked users</span>
|
||||
</div>
|
||||
<div class="text">
|
||||
Blocked users cannot send you friend requests.
|
||||
</div>
|
||||
</div>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<td>name</td>
|
||||
<td>blocked on</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="tab hidden" data-tab="dangerZone">
|
||||
<div class="section resetAccount">
|
||||
<div class="title">
|
||||
|
|
|
|||
82
frontend/src/html/pages/friends.html
Normal file
82
frontend/src/html/pages/friends.html
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<div class="page pageFriends hidden" id="pageFriends">
|
||||
<div class="preloader hidden">
|
||||
<div class="icon">
|
||||
<i class="fas fa-fw fa-spin fa-circle-notch"></i>
|
||||
</div>
|
||||
<div class="barWrapper hidden">
|
||||
<div class="bar">
|
||||
<div class="fill"></div>
|
||||
</div>
|
||||
<div class="text"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<div class="pendingRequests">
|
||||
<div class="bigTitle">
|
||||
<i class="fas fa-user-plus fa-fw"></i>
|
||||
Incoming Requests
|
||||
</div>
|
||||
<div class="error hidden">
|
||||
<i class="fas fa-times"></i>
|
||||
<p>Something went wrong</p>
|
||||
</div>
|
||||
<table width="100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<td>user</td>
|
||||
<td>date</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="friends">
|
||||
<div class="titleAndButton">
|
||||
<div class="bigTitle">
|
||||
<i class="fas fa-user-friends fa-fw"></i>
|
||||
Friends
|
||||
<i class="spinner hidden fas fa-circle-notch fa-spin"></i>
|
||||
</div>
|
||||
<button class="button friendAdd">
|
||||
<i class="fas fa-plus fa-fw"></i>
|
||||
add friend
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="loading hidden">
|
||||
<i class="fas fa-circle-notch fa-spin"></i>
|
||||
</div>
|
||||
<div class="error hidden">
|
||||
<i class="fas fa-times"></i>
|
||||
<p>Something went wrong</p>
|
||||
</div>
|
||||
<div class="nodata hidden">You don't have any friends :(</div>
|
||||
|
||||
<table width="100%">
|
||||
<thead>
|
||||
<tr>
|
||||
<td data-sort-property="name">name</td>
|
||||
<td data-sort-property="addedAt">friends for</td>
|
||||
<td data-sort-property="xp">level</td>
|
||||
<td
|
||||
data-sort-property="completedTests"
|
||||
aria-label="completed / started"
|
||||
data-balloon-pos="up"
|
||||
>
|
||||
tests
|
||||
</td>
|
||||
<td data-sort-property="timeTyping">time typing</td>
|
||||
<td data-sort-property="streak.length">streak</td>
|
||||
<td data-sort-property="top15.wpm">time 15 pb</td>
|
||||
<td data-sort-property="top60.wpm">time 60 pb</td>
|
||||
<td width="1px"></td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -50,6 +50,7 @@
|
|||
<load src="html/pages/test.html" />
|
||||
<load src="html/pages/404.html" />
|
||||
<load src="html/pages/account-settings.html" />
|
||||
<load src="html/pages/friends.html" />
|
||||
<load src="html/pages/leaderboards.html" />
|
||||
</main>
|
||||
<load src="html/footer.html" />
|
||||
|
|
|
|||
|
|
@ -116,16 +116,13 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
&[data-tab="apeKeys"] {
|
||||
&[data-tab="apeKeys"],
|
||||
&[data-tab="blockedUsers"] {
|
||||
table {
|
||||
width: 100%;
|
||||
border-spacing: 0;
|
||||
border-collapse: collapse;
|
||||
|
||||
tr td:first-child {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
tr.me {
|
||||
td {
|
||||
color: var(--main-color);
|
||||
|
|
@ -185,6 +182,21 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
&[data-tab="apeKeys"] {
|
||||
tr td:first-child {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
&[data-tab="blockedUsers"] {
|
||||
tr td:first-child a {
|
||||
text-decoration: none;
|
||||
color: var(--text-color);
|
||||
}
|
||||
tr td:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// .right {
|
||||
|
|
|
|||
|
|
@ -406,10 +406,10 @@ key {
|
|||
line-height: 2em;
|
||||
color: transparent;
|
||||
border-radius: 100rem;
|
||||
right: 0.5em;
|
||||
top: 0.5em;
|
||||
box-shadow: 0 0 0 0.5em var(--bg-color);
|
||||
transition: 0.125s;
|
||||
right: 0.5em;
|
||||
top: 0.5em;
|
||||
}
|
||||
|
||||
#fpsCounter {
|
||||
|
|
@ -448,3 +448,17 @@ key {
|
|||
pointer-events: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
//sorted-tables
|
||||
.headerSorted {
|
||||
font-weight: bold;
|
||||
}
|
||||
table {
|
||||
z-index: 0;
|
||||
td.sortable:hover {
|
||||
cursor: pointer;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
background-color: var(--sub-alt-color);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
143
frontend/src/styles/friends.scss
Normal file
143
frontend/src/styles/friends.scss
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
.pageFriends {
|
||||
.bigTitle {
|
||||
color: var(--sub-color);
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.friendAdd {
|
||||
padding-left: 1em;
|
||||
padding-right: 1em;
|
||||
}
|
||||
|
||||
.titleAndButton {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
margin-bottom: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nodata {
|
||||
color: var(--sub-color);
|
||||
padding: 5rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.pendingRequests,
|
||||
.friends {
|
||||
margin-bottom: 4rem;
|
||||
table {
|
||||
border-spacing: 0;
|
||||
border-collapse: collapse;
|
||||
color: var(--text-color);
|
||||
--padding: 1em 1.5rem;
|
||||
|
||||
.small {
|
||||
font-size: 0.75em;
|
||||
}
|
||||
|
||||
thead {
|
||||
color: var(--sub-color);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
tr.me {
|
||||
color: var(--main-color);
|
||||
}
|
||||
|
||||
tbody tr:nth-child(odd) td {
|
||||
background: var(--sub-alt-color);
|
||||
}
|
||||
|
||||
tbody td:first-child {
|
||||
border-radius: var(--roundness) 0 0 var(--roundness);
|
||||
}
|
||||
tbody td:last-child {
|
||||
border-radius: 0 var(--roundness) var(--roundness) 0;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: var(--padding);
|
||||
appearance: unset;
|
||||
|
||||
&:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
//don't wrap friendsfor rand streak into multiple lines
|
||||
&:nth-child(2),
|
||||
&:nth-child(6) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.sub {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
// .actions button {
|
||||
// opacity: 0;
|
||||
// transition: opacity 0.125s;
|
||||
// }
|
||||
|
||||
// tr:hover button {
|
||||
// opacity: 1;
|
||||
// }
|
||||
}
|
||||
}
|
||||
.pendingRequests {
|
||||
table tr {
|
||||
td:first-child a {
|
||||
text-decoration: none;
|
||||
color: var(--text-color);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.friends {
|
||||
.avatarNameBadge {
|
||||
display: grid;
|
||||
grid-template-columns: 1.25em max-content auto;
|
||||
gap: 0.5em;
|
||||
place-items: center left;
|
||||
.avatarPlaceholder {
|
||||
width: 1.25em;
|
||||
height: 1.25em;
|
||||
font-size: 1.25em;
|
||||
// background: var(--sub-color);
|
||||
color: var(--sub-color);
|
||||
// display: grid;
|
||||
// place-content: center center;
|
||||
border-radius: 100%;
|
||||
}
|
||||
.entryName {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.avatarPlaceholder,
|
||||
.avatar {
|
||||
grid-row: 1/2;
|
||||
grid-column: 1/2;
|
||||
.userIcon {
|
||||
color: var(--sub-color);
|
||||
}
|
||||
}
|
||||
.badge {
|
||||
font-size: 0.6em;
|
||||
}
|
||||
.flagsAndBadge {
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
color: var(--sub-color);
|
||||
place-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
.loading {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 3em;
|
||||
color: var(--sub-color);
|
||||
padding: 1em;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
@import "buttons", "fonts", "404", "ads", "about", "account", "animations",
|
||||
"banners", "caret", "commandline", "core", "footer", "inputs", "keymap",
|
||||
"login", "monkey", "nav", "notifications", "popups", "profile", "scroll",
|
||||
"settings", "account-settings", "leaderboards", "test", "loading",
|
||||
"settings", "account-settings", "leaderboards", "test", "loading", "friends",
|
||||
"media-queries";
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@
|
|||
.tabs {
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-flow: row;
|
||||
button {
|
||||
justify-content: center;
|
||||
padding: 1em 0.5em;
|
||||
|
|
@ -108,6 +108,7 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pageSettings {
|
||||
.accountSettingsNotice {
|
||||
button {
|
||||
|
|
@ -235,8 +236,13 @@
|
|||
// border-radius: 0.15em;
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
.pageFriends {
|
||||
.content .friends table {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
@media (pointer: coarse) and (max-width: 778px) {
|
||||
#restartTestButton {
|
||||
display: block !important;
|
||||
|
|
|
|||
|
|
@ -101,4 +101,20 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
.pageFriends {
|
||||
.content .friends table {
|
||||
td:nth-child(3) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pageAccountSettings [data-tab="blockedUsers"] {
|
||||
table {
|
||||
font-size: 0.75rem;
|
||||
td:nth-child(2) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -283,4 +283,13 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
.pageFriends {
|
||||
.content .friends table {
|
||||
td:nth-child(4),
|
||||
td:nth-child(7),
|
||||
td:nth-child(8) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,13 +15,6 @@
|
|||
.ad.ad-h-s {
|
||||
display: grid;
|
||||
}
|
||||
.pageAccountSettings {
|
||||
.main {
|
||||
.tabs {
|
||||
grid-auto-flow: row;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pageLeaderboards {
|
||||
.content .bigtitle .text:after {
|
||||
|
|
@ -322,4 +315,19 @@
|
|||
.modalWrapper .modal .inputs.withLabel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.pageFriends {
|
||||
.content .friends table {
|
||||
td:nth-child(5),
|
||||
td:nth-child(6) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pageAccountSettings [data-tab="blockedUsers"] {
|
||||
table {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,4 +90,13 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
.pageFriends {
|
||||
.content .friends table {
|
||||
font-size: 0.9rem;
|
||||
|
||||
.badge .text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,12 @@ nav {
|
|||
gap: 0.33em;
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
|
||||
.notificationBubble {
|
||||
left: 3em;
|
||||
top: 0.5em;
|
||||
}
|
||||
|
||||
.spinner,
|
||||
.avatar {
|
||||
grid-column: 1/2;
|
||||
|
|
@ -191,7 +197,8 @@ nav {
|
|||
border-radius: var(--roundness);
|
||||
gap: 0.25em;
|
||||
|
||||
* {
|
||||
a,
|
||||
button {
|
||||
justify-content: left;
|
||||
padding-left: 0;
|
||||
border-radius: 0;
|
||||
|
|
@ -210,6 +217,18 @@ nav {
|
|||
border-radius: var(--roundness);
|
||||
}
|
||||
}
|
||||
|
||||
& .goToFriends {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
text-align: left;
|
||||
.notificationBubble {
|
||||
margin-right: 0.5em;
|
||||
position: unset;
|
||||
box-shadow: 0 0 0 0.5em var(--sub-alt-color);
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
&:hover,
|
||||
|
|
|
|||
|
|
@ -4,9 +4,13 @@ import { promiseWithResolvers } from "../utils/misc";
|
|||
|
||||
let config: Configuration | undefined = undefined;
|
||||
|
||||
const { promise: configPromise, resolve } = promiseWithResolvers<boolean>();
|
||||
const {
|
||||
promise: configurationPromise,
|
||||
resolve,
|
||||
reject,
|
||||
} = promiseWithResolvers<boolean>();
|
||||
|
||||
export { configPromise };
|
||||
export { configurationPromise };
|
||||
|
||||
export function get(): Configuration | undefined {
|
||||
return config;
|
||||
|
|
@ -16,7 +20,9 @@ export async function sync(): Promise<void> {
|
|||
const response = await Ape.configuration.get();
|
||||
|
||||
if (response.status !== 200) {
|
||||
console.error("Could not fetch configuration", response.body.message);
|
||||
const message = `Could not fetch configuration: ${response.body.message}`;
|
||||
console.error(message);
|
||||
reject(message);
|
||||
return;
|
||||
} else {
|
||||
config = response.body.data ?? undefined;
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from "../elements/test-activity-calendar";
|
||||
import { Preset } from "@monkeytype/schemas/presets";
|
||||
import { Language } from "@monkeytype/schemas/languages";
|
||||
import { ConnectionStatus } from "@monkeytype/schemas/connections";
|
||||
|
||||
export type SnapshotUserTag = UserTag & {
|
||||
active?: boolean;
|
||||
|
|
@ -84,6 +85,7 @@ export type Snapshot = Omit<
|
|||
xp: number;
|
||||
testActivity?: ModifiableTestActivityCalendar;
|
||||
testActivityByYear?: { [key: string]: TestActivityCalendar };
|
||||
connections: Record<string, ConnectionStatus | "incoming">;
|
||||
};
|
||||
|
||||
export type SnapshotPreset = Preset & {
|
||||
|
|
@ -131,6 +133,7 @@ const defaultSnap = {
|
|||
60: { english: { count: 0, rank: 0 } },
|
||||
},
|
||||
},
|
||||
connections: {},
|
||||
} as Snapshot;
|
||||
|
||||
export function getDefaultSnapshot(): Snapshot {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import * as PageLogin from "../pages/login";
|
|||
import * as PageLoading from "../pages/loading";
|
||||
import * as PageProfile from "../pages/profile";
|
||||
import * as PageProfileSearch from "../pages/profile-search";
|
||||
import * as Friends from "../pages/friends";
|
||||
import * as Page404 from "../pages/404";
|
||||
import * as PageLeaderboards from "../pages/leaderboards";
|
||||
import * as PageAccountSettings from "../pages/account-settings";
|
||||
|
|
@ -114,6 +115,9 @@ async function showSyncLoading({
|
|||
PageLoading.page.element.addClass("hidden");
|
||||
}
|
||||
|
||||
// Global abort controller for keyframe promises
|
||||
let keyframeAbortController: AbortController | null = null;
|
||||
|
||||
async function getLoadingPromiseWithBarKeyframes(
|
||||
loadingOptions: Extract<
|
||||
NonNullable<Page<unknown>["loadingOptions"]>,
|
||||
|
|
@ -122,13 +126,16 @@ async function getLoadingPromiseWithBarKeyframes(
|
|||
fillDivider: number,
|
||||
fillOffset: number
|
||||
): Promise<void> {
|
||||
let aborted = false;
|
||||
let loadingPromise = loadingOptions.loadingPromise();
|
||||
|
||||
// Animate bar keyframes, but allow aborting if loading.promise finishes first
|
||||
// Create abort controller for this keyframe sequence
|
||||
const localAbortController = new AbortController();
|
||||
keyframeAbortController = localAbortController;
|
||||
|
||||
// Animate bar keyframes, but allow aborting if loading.promise finishes first or if globally aborted
|
||||
const keyframePromise = (async () => {
|
||||
for (const keyframe of loadingOptions.keyframes) {
|
||||
if (aborted) break;
|
||||
if (localAbortController.signal.aborted) break;
|
||||
if (keyframe.text !== undefined) {
|
||||
PageLoading.updateText(keyframe.text);
|
||||
}
|
||||
|
|
@ -144,12 +151,18 @@ async function getLoadingPromiseWithBarKeyframes(
|
|||
keyframePromise,
|
||||
(async () => {
|
||||
await loadingPromise;
|
||||
aborted = true;
|
||||
localAbortController.abort();
|
||||
})(),
|
||||
]);
|
||||
|
||||
// Always wait for loading.promise to finish before continuing
|
||||
await loadingPromise;
|
||||
|
||||
// Clean up the abort controller
|
||||
if (keyframeAbortController === localAbortController) {
|
||||
keyframeAbortController = null;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -186,6 +199,7 @@ export async function change(
|
|||
login: PageLogin.page,
|
||||
profile: PageProfile.page,
|
||||
profileSearch: PageProfileSearch.page,
|
||||
friends: Friends.page,
|
||||
404: Page404.page,
|
||||
accountSettings: PageAccountSettings.page,
|
||||
leaderboards: PageLeaderboards.page,
|
||||
|
|
@ -234,7 +248,18 @@ export async function change(
|
|||
easingMethod,
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up abort controller after successful loading
|
||||
if (keyframeAbortController) {
|
||||
keyframeAbortController = null;
|
||||
}
|
||||
} catch (error) {
|
||||
// Abort any running keyframe promises
|
||||
if (keyframeAbortController) {
|
||||
keyframeAbortController.abort();
|
||||
keyframeAbortController = null;
|
||||
}
|
||||
|
||||
pages.loading.element.addClass("active");
|
||||
ActivePage.set(pages.loading.id);
|
||||
Focus.set(false);
|
||||
|
|
|
|||
|
|
@ -144,6 +144,21 @@ const routes: Route[] = [
|
|||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/friends",
|
||||
load: async (_params, options) => {
|
||||
if (!isAuthAvailable()) {
|
||||
await navigate("/", options);
|
||||
return;
|
||||
}
|
||||
if (!isAuthenticated()) {
|
||||
await navigate("/login", options);
|
||||
return;
|
||||
}
|
||||
|
||||
await PageController.change("friends", options);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export async function navigate(
|
||||
|
|
|
|||
|
|
@ -19,12 +19,19 @@ const flags: UserFlag[] = [
|
|||
color: "var(--error-color)",
|
||||
test: (it) => it.lbOptOut === true,
|
||||
},
|
||||
{
|
||||
name: "Friend",
|
||||
description: "Friend :)",
|
||||
icon: "fa-user-friends",
|
||||
test: (it) => it.isFriend === true,
|
||||
},
|
||||
];
|
||||
|
||||
export type SupportsFlags = {
|
||||
isPremium?: boolean;
|
||||
banned?: boolean;
|
||||
lbOptOut?: boolean;
|
||||
isFriend?: boolean;
|
||||
};
|
||||
|
||||
type UserFlag = {
|
||||
|
|
@ -39,6 +46,7 @@ type UserFlag = {
|
|||
|
||||
type UserFlagOptions = {
|
||||
iconsOnly?: boolean;
|
||||
isFriend?: boolean;
|
||||
};
|
||||
|
||||
const USER_FLAG_OPTIONS_DEFAULT: UserFlagOptions = {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import Ape from "./ape";
|
||||
import * as Notifications from "./elements/notifications";
|
||||
import { isAuthenticated } from "./firebase";
|
||||
import { isAuthenticated, getAuthenticatedUser } from "./firebase";
|
||||
import * as ConnectionState from "./states/connection";
|
||||
import { lastElementFromArray } from "./utils/arrays";
|
||||
import { migrateConfig } from "./utils/config";
|
||||
|
|
@ -31,6 +31,11 @@ import { FunboxMetadata } from "../../../packages/funbox/src/types";
|
|||
import { getFirstDayOfTheWeek } from "./utils/date-and-time";
|
||||
import { Language } from "@monkeytype/schemas/languages";
|
||||
import * as AuthEvent from "./observables/auth-event";
|
||||
import {
|
||||
configurationPromise,
|
||||
get as getServerConfiguration,
|
||||
} from "./ape/server-configuration";
|
||||
import { Connection } from "@monkeytype/schemas/connections";
|
||||
|
||||
let dbSnapshot: Snapshot | undefined;
|
||||
const firstDayOfTheWeek = getFirstDayOfTheWeek();
|
||||
|
|
@ -82,14 +87,22 @@ export function setSnapshot(
|
|||
export async function initSnapshot(): Promise<Snapshot | false> {
|
||||
//send api request with token that returns tags, presets, and data needed for snap
|
||||
const snap = getDefaultSnapshot();
|
||||
await configurationPromise;
|
||||
|
||||
try {
|
||||
if (!isAuthenticated()) return false;
|
||||
|
||||
const [userResponse, configResponse, presetsResponse] = await Promise.all([
|
||||
Ape.users.get(),
|
||||
Ape.configs.get(),
|
||||
Ape.presets.get(),
|
||||
]);
|
||||
const connectionsRequest = getServerConfiguration()?.connections.enabled
|
||||
? Ape.connections.get()
|
||||
: { status: 200, body: { message: "", data: [] } };
|
||||
|
||||
const [userResponse, configResponse, presetsResponse, connectionsResponse] =
|
||||
await Promise.all([
|
||||
Ape.users.get(),
|
||||
Ape.configs.get(),
|
||||
Ape.presets.get(),
|
||||
connectionsRequest,
|
||||
]);
|
||||
|
||||
if (userResponse.status !== 200) {
|
||||
throw new SnapshotInitError(
|
||||
|
|
@ -109,10 +122,17 @@ export async function initSnapshot(): Promise<Snapshot | false> {
|
|||
presetsResponse.status
|
||||
);
|
||||
}
|
||||
if (connectionsResponse.status !== 200) {
|
||||
throw new SnapshotInitError(
|
||||
`${connectionsResponse.body.message} (connections)`,
|
||||
connectionsResponse.status
|
||||
);
|
||||
}
|
||||
|
||||
const userData = userResponse.body.data;
|
||||
const configData = configResponse.body.data;
|
||||
const presetsData = presetsResponse.body.data;
|
||||
const connectionsData = connectionsResponse.body.data;
|
||||
|
||||
if (userData === null) {
|
||||
throw new SnapshotInitError(
|
||||
|
|
@ -249,6 +269,8 @@ export async function initSnapshot(): Promise<Snapshot | false> {
|
|||
);
|
||||
}
|
||||
|
||||
snap.connections = convertConnections(connectionsData);
|
||||
|
||||
dbSnapshot = snap;
|
||||
return dbSnapshot;
|
||||
} catch (e) {
|
||||
|
|
@ -1083,6 +1105,48 @@ export async function getTestActivityCalendar(
|
|||
return dbSnapshot.testActivityByYear[yearString];
|
||||
}
|
||||
|
||||
export function mergeConnections(connections: Connection[]): void {
|
||||
const snapshot = getSnapshot();
|
||||
if (!snapshot) return;
|
||||
|
||||
const update = convertConnections(connections);
|
||||
|
||||
for (const [key, value] of Object.entries(update)) {
|
||||
snapshot.connections[key] = value;
|
||||
}
|
||||
|
||||
setSnapshot(snapshot);
|
||||
}
|
||||
|
||||
function convertConnections(
|
||||
connectionsData: Connection[]
|
||||
): Snapshot["connections"] {
|
||||
return Object.fromEntries(
|
||||
connectionsData.map((connection) => {
|
||||
const isMyRequest =
|
||||
getAuthenticatedUser()?.uid === connection.initiatorUid;
|
||||
|
||||
return [
|
||||
isMyRequest ? connection.receiverUid : connection.initiatorUid,
|
||||
connection.status === "pending" && !isMyRequest
|
||||
? "incoming"
|
||||
: connection.status,
|
||||
];
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function isFriend(uid: string | undefined): boolean {
|
||||
if (uid === undefined || uid === getAuthenticatedUser()?.uid) return false;
|
||||
|
||||
const snapshot = getSnapshot();
|
||||
if (!snapshot) return false;
|
||||
|
||||
return Object.entries(snapshot.connections).some(
|
||||
([receiverUid, status]) => receiverUid === uid && status === "accepted"
|
||||
);
|
||||
}
|
||||
|
||||
// export async function DB.getLocalTagPB(tagId) {
|
||||
// function cont() {
|
||||
// let ret = 0;
|
||||
|
|
|
|||
|
|
@ -75,6 +75,28 @@ export function update(): void {
|
|||
}
|
||||
);
|
||||
}
|
||||
|
||||
updateFriendRequestsIndicator();
|
||||
}
|
||||
|
||||
export function updateFriendRequestsIndicator(): void {
|
||||
const friends = getSnapshot()?.connections;
|
||||
|
||||
if (friends !== undefined) {
|
||||
const pendingFriendRequests = Object.values(friends).filter(
|
||||
(it) => it === "incoming"
|
||||
).length;
|
||||
if (pendingFriendRequests > 0) {
|
||||
$("nav .view-account > .notificationBubble").removeClass("hidden");
|
||||
$("nav .goToFriends > .notificationBubble")
|
||||
.removeClass("hidden")
|
||||
.text(pendingFriendRequests);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$("nav .view-account > .notificationBubble").addClass("hidden");
|
||||
$("nav .goToFriends > .notificationBubble").addClass("hidden");
|
||||
}
|
||||
|
||||
const coarse = window.matchMedia("(pointer:coarse)")?.matches;
|
||||
|
|
|
|||
107
frontend/src/ts/elements/account-settings/blocked-user-table.ts
Normal file
107
frontend/src/ts/elements/account-settings/blocked-user-table.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import * as Notifications from "../../elements/notifications";
|
||||
import { Connection } from "@monkeytype/schemas/connections";
|
||||
import Ape from "../../ape";
|
||||
import { format } from "date-fns/format";
|
||||
import { isAuthenticated } from "../../firebase";
|
||||
import { getReceiverUid } from "../../pages/friends";
|
||||
import * as DB from "../../db";
|
||||
import { updateFriendRequestsIndicator } from "../account-button";
|
||||
|
||||
let blockedUsers: Connection[] = [];
|
||||
const element = $("#pageAccountSettings .tab[data-tab='blockedUsers']");
|
||||
|
||||
async function getData(): Promise<boolean> {
|
||||
showLoaderRow();
|
||||
|
||||
if (!isAuthenticated()) {
|
||||
blockedUsers = [];
|
||||
return false;
|
||||
}
|
||||
|
||||
const response = await Ape.connections.get({
|
||||
query: { status: "blocked", type: "incoming" },
|
||||
});
|
||||
|
||||
if (response.status !== 200) {
|
||||
blockedUsers = [];
|
||||
Notifications.add(
|
||||
"Error getting blocked users: " + response.body.message,
|
||||
-1
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
blockedUsers = response.body.data;
|
||||
return true;
|
||||
}
|
||||
export async function update(): Promise<void> {
|
||||
await getData();
|
||||
refreshList();
|
||||
}
|
||||
|
||||
function showLoaderRow(): void {
|
||||
const table = element.find("table tbody");
|
||||
|
||||
table.empty();
|
||||
table.append(
|
||||
"<tr><td colspan='3' style='text-align: center;font-size:1rem;'><i class='fas fa-spin fa-circle-notch'></i></td></tr>"
|
||||
);
|
||||
}
|
||||
|
||||
function refreshList(): void {
|
||||
const table = element.find("table tbody");
|
||||
table.empty();
|
||||
if (blockedUsers.length === 0) {
|
||||
table.append(
|
||||
"<tr><td colspan='3' style='text-align: center;'>No blocked users</td></tr>"
|
||||
);
|
||||
return;
|
||||
}
|
||||
const content = blockedUsers.map(
|
||||
(blocked) => `
|
||||
<tr data-id="${blocked._id}" data-uid="${getReceiverUid(blocked)}">
|
||||
<td><a href="${location.origin}/profile/${
|
||||
blocked.initiatorUid
|
||||
}?isUid" router-link>${blocked.initiatorName}</a></td>
|
||||
<td>${format(new Date(blocked.lastModified), "dd MMM yyyy HH:mm")}</td>
|
||||
<td>
|
||||
<button class="delete">
|
||||
<i class="fas fa-fw fa-trash-alt"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`
|
||||
);
|
||||
table.append(content.join());
|
||||
}
|
||||
|
||||
element.on("click", "table button.delete", async (e) => {
|
||||
const row = (e.target as HTMLElement).closest("tr") as HTMLElement;
|
||||
const id = row.dataset["id"];
|
||||
|
||||
if (id === undefined) {
|
||||
throw new Error("Cannot find id of target.");
|
||||
}
|
||||
|
||||
row.querySelectorAll("button").forEach((button) => (button.disabled = true));
|
||||
|
||||
const response = await Ape.connections.delete({ params: { id } });
|
||||
if (response.status !== 200) {
|
||||
Notifications.add(`Cannot unblock user: ${response.body.message}`, -1);
|
||||
} else {
|
||||
blockedUsers = blockedUsers.filter((it) => it._id !== id);
|
||||
refreshList();
|
||||
|
||||
const snapshot = DB.getSnapshot();
|
||||
if (snapshot) {
|
||||
const uid = row.dataset["uid"];
|
||||
if (uid === undefined) {
|
||||
throw new Error("Cannot find uid of target.");
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete, @typescript-eslint/no-unsafe-member-access
|
||||
delete snapshot.connections[uid];
|
||||
updateFriendRequestsIndicator();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import * as DB from "../db";
|
||||
import { format } from "date-fns/format";
|
||||
import { format as dateFormat } from "date-fns/format";
|
||||
import { differenceInDays } from "date-fns/differenceInDays";
|
||||
import * as Misc from "../utils/misc";
|
||||
import * as Numbers from "@monkeytype/util/numbers";
|
||||
|
|
@ -11,12 +11,15 @@ import * as ActivePage from "../states/active-page";
|
|||
import { formatDistanceToNowStrict } from "date-fns/formatDistanceToNowStrict";
|
||||
import { getHtmlByUserFlags } from "../controllers/user-flag-controller";
|
||||
import Format from "../utils/format";
|
||||
import { UserProfile, RankAndCount } from "@monkeytype/schemas/users";
|
||||
import { abbreviateNumber, convertRemToPixels } from "../utils/numbers";
|
||||
import { UserProfile } from "@monkeytype/schemas/users";
|
||||
import { convertRemToPixels } from "../utils/numbers";
|
||||
import { secondsToString } from "../utils/date-and-time";
|
||||
import { getAuthenticatedUser } from "../firebase";
|
||||
import { Snapshot } from "../constants/default-snapshot";
|
||||
import { getAvatarElement } from "../utils/discord-avatar";
|
||||
import { formatXp } from "../utils/levels";
|
||||
import { formatTopPercentage } from "../utils/misc";
|
||||
import { get as getServerConfiguration } from "../ape/server-configuration";
|
||||
|
||||
type ProfileViewPaths = "profile" | "account";
|
||||
type UserProfileOrSnapshot = UserProfile | Snapshot;
|
||||
|
|
@ -68,7 +71,11 @@ export async function update(
|
|||
}
|
||||
|
||||
details.find(".name").text(profile.name);
|
||||
details.find(".userFlags").html(getHtmlByUserFlags(profile));
|
||||
details
|
||||
.find(".userFlags")
|
||||
.html(
|
||||
getHtmlByUserFlags({ ...profile, isFriend: DB.isFriend(profile.uid) })
|
||||
);
|
||||
|
||||
if (profile.lbOptOut === true) {
|
||||
if (where === "profile") {
|
||||
|
|
@ -87,7 +94,8 @@ export async function update(
|
|||
updateNameFontSize(where);
|
||||
}, 10);
|
||||
|
||||
const joinedText = "Joined " + format(profile.addedAt ?? 0, "dd MMM yyyy");
|
||||
const joinedText =
|
||||
"Joined " + dateFormat(profile.addedAt ?? 0, "dd MMM yyyy");
|
||||
const creationDate = new Date(profile.addedAt);
|
||||
const diffDays = differenceInDays(new Date(), creationDate);
|
||||
const balloonText = `${diffDays} day${diffDays !== 1 ? "s" : ""} ago`;
|
||||
|
|
@ -184,21 +192,9 @@ export async function update(
|
|||
.attr("aria-label", hoverText)
|
||||
.attr("data-balloon-break", "");
|
||||
|
||||
let completedPercentage = "";
|
||||
let restartRatio = "";
|
||||
if (
|
||||
profile.typingStats.completedTests !== undefined &&
|
||||
profile.typingStats.startedTests !== undefined
|
||||
) {
|
||||
completedPercentage = Math.floor(
|
||||
(profile.typingStats.completedTests / profile.typingStats.startedTests) *
|
||||
100
|
||||
).toString();
|
||||
restartRatio = (
|
||||
(profile.typingStats.startedTests - profile.typingStats.completedTests) /
|
||||
profile.typingStats.completedTests
|
||||
).toFixed(1);
|
||||
}
|
||||
const { completedPercentage, restartRatio } = Misc.formatTypingStatsRatio(
|
||||
profile.typingStats
|
||||
);
|
||||
|
||||
const typingStatsEl = details.find(".typingStats");
|
||||
typingStatsEl
|
||||
|
|
@ -326,8 +322,6 @@ export async function update(
|
|||
profileElement.find(".userReportButton").removeClass("hidden");
|
||||
}
|
||||
|
||||
//structure
|
||||
|
||||
const bioAndKey = bio || keyboard;
|
||||
|
||||
if (!bio) {
|
||||
|
|
@ -371,6 +365,8 @@ export async function update(
|
|||
} else if (socials && bioAndKey) {
|
||||
details.addClass("both");
|
||||
}
|
||||
|
||||
updateFriendRequestButton();
|
||||
}
|
||||
|
||||
export function updateXp(
|
||||
|
|
@ -439,6 +435,26 @@ export function updateNameFontSize(where: ProfileViewPaths): void {
|
|||
nameField.style.fontSize = `${finalFontSize}px`;
|
||||
}
|
||||
|
||||
export function updateFriendRequestButton(): void {
|
||||
const myUid = getAuthenticatedUser()?.uid;
|
||||
const profileUid = document
|
||||
.querySelector(".profile")
|
||||
?.getAttribute("uid") as string;
|
||||
const button = document.querySelector(".profile .addFriendButton");
|
||||
|
||||
const myProfile = myUid === profileUid;
|
||||
const hasRequest = DB.getSnapshot()?.connections[profileUid] !== undefined;
|
||||
const featureEnabled = getServerConfiguration()?.connections.enabled;
|
||||
|
||||
if (!featureEnabled || myUid === undefined || myProfile) {
|
||||
button?.classList.add("hidden");
|
||||
} else if (hasRequest) {
|
||||
button?.classList.add("disabled");
|
||||
} else {
|
||||
button?.classList.remove("disabled");
|
||||
button?.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
const throttledEvent = throttle(1000, () => {
|
||||
const activePage = ActivePage.get();
|
||||
if (activePage && ["account", "profile"].includes(activePage)) {
|
||||
|
|
@ -449,17 +465,3 @@ const throttledEvent = throttle(1000, () => {
|
|||
$(window).on("resize", () => {
|
||||
throttledEvent();
|
||||
});
|
||||
|
||||
function formatTopPercentage(lbRank: RankAndCount): string {
|
||||
if (lbRank.rank === undefined) return "-";
|
||||
if (lbRank.rank === 1) return "GOAT";
|
||||
return "Top " + Numbers.roundTo2((lbRank.rank / lbRank.count) * 100) + "%";
|
||||
}
|
||||
|
||||
function formatXp(xp: number): string {
|
||||
if (xp < 1000) {
|
||||
return Math.round(xp).toString();
|
||||
} else {
|
||||
return abbreviateNumber(xp);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,9 +34,9 @@ export function init(
|
|||
update(element, calendar);
|
||||
}
|
||||
|
||||
export function clear(element: HTMLElement): void {
|
||||
element.classList.add("hidden");
|
||||
element.querySelector(".activity")?.replaceChildren();
|
||||
export function clear(element?: HTMLElement): void {
|
||||
element?.classList.add("hidden");
|
||||
element?.querySelector(".activity")?.replaceChildren();
|
||||
}
|
||||
|
||||
function update(element: HTMLElement, calendar?: TestActivityCalendar): void {
|
||||
|
|
|
|||
|
|
@ -155,13 +155,15 @@ function disableInput(): void {
|
|||
validateWithIndicator(nameInputEl, {
|
||||
schema: UserNameSchema,
|
||||
isValid: async (name: string) => {
|
||||
const checkNameResponse = (
|
||||
await Ape.users.getNameAvailability({
|
||||
params: { name: name },
|
||||
})
|
||||
).status;
|
||||
const checkNameResponse = await Ape.users.getNameAvailability({
|
||||
params: { name: name },
|
||||
});
|
||||
|
||||
return checkNameResponse === 200 ? true : "Name not available";
|
||||
return (
|
||||
(checkNameResponse.status === 200 &&
|
||||
checkNameResponse.body.data.available) ||
|
||||
"Name not available"
|
||||
);
|
||||
},
|
||||
debounceDelay: 1000,
|
||||
callback: (result) => {
|
||||
|
|
|
|||
|
|
@ -480,13 +480,15 @@ list.updateName = new SimpleModal({
|
|||
validation: {
|
||||
schema: UserNameSchema,
|
||||
isValid: async (newName: string) => {
|
||||
const checkNameResponse = (
|
||||
await Ape.users.getNameAvailability({
|
||||
params: { name: newName },
|
||||
})
|
||||
).status;
|
||||
const checkNameResponse = await Ape.users.getNameAvailability({
|
||||
params: { name: newName },
|
||||
});
|
||||
|
||||
return checkNameResponse === 200 ? true : "Name not available";
|
||||
return (
|
||||
(checkNameResponse.status === 200 &&
|
||||
checkNameResponse.body.data.available) ||
|
||||
"Name not available"
|
||||
);
|
||||
},
|
||||
debounceDelay: 1000,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import Ape from "../ape";
|
|||
import * as StreakHourOffsetModal from "../modals/streak-hour-offset";
|
||||
import * as Loader from "../elements/loader";
|
||||
import * as ApeKeyTable from "../elements/account-settings/ape-key-table";
|
||||
import * as BlockedUserTable from "../elements/account-settings/blocked-user-table";
|
||||
import * as Notifications from "../elements/notifications";
|
||||
import { z } from "zod";
|
||||
import * as AuthEvent from "../observables/auth-event";
|
||||
|
|
@ -15,7 +16,13 @@ import * as AuthEvent from "../observables/auth-event";
|
|||
const pageElement = $(".page.pageAccountSettings");
|
||||
|
||||
const StateSchema = z.object({
|
||||
tab: z.enum(["authentication", "account", "apeKeys", "dangerZone"]),
|
||||
tab: z.enum([
|
||||
"authentication",
|
||||
"account",
|
||||
"apeKeys",
|
||||
"dangerZone",
|
||||
"blockedUsers",
|
||||
]),
|
||||
});
|
||||
type State = z.infer<typeof StateSchema>;
|
||||
|
||||
|
|
@ -182,6 +189,7 @@ export function updateUI(): void {
|
|||
updateIntegrationSections();
|
||||
updateAccountSections();
|
||||
void ApeKeyTable.update(updateUI);
|
||||
void BlockedUserTable.update();
|
||||
updateTabs();
|
||||
page.setUrlParams(state);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1318,7 +1318,7 @@ ConfigEvent.subscribe((eventKey) => {
|
|||
}
|
||||
});
|
||||
|
||||
export const page = new Page({
|
||||
export const page = new Page<undefined>({
|
||||
id: "account",
|
||||
element: $(".page.pageAccount"),
|
||||
path: "/account",
|
||||
|
|
|
|||
563
frontend/src/ts/pages/friends.ts
Normal file
563
frontend/src/ts/pages/friends.ts
Normal file
|
|
@ -0,0 +1,563 @@
|
|||
import Page from "./page";
|
||||
import * as Skeleton from "../utils/skeleton";
|
||||
import { SimpleModal } from "../utils/simple-modal";
|
||||
import Ape from "../ape";
|
||||
import {
|
||||
intervalToDuration,
|
||||
format as dateFormat,
|
||||
formatDuration,
|
||||
formatDistanceToNow,
|
||||
format,
|
||||
} from "date-fns";
|
||||
import * as Notifications from "../elements/notifications";
|
||||
import { isSafeNumber } from "@monkeytype/util/numbers";
|
||||
import { getHTMLById as getBadgeHTMLbyId } from "../controllers/badge-controller";
|
||||
import { formatXp, getXpDetails } from "../utils/levels";
|
||||
import { secondsToString } from "../utils/date-and-time";
|
||||
import { PersonalBest } from "@monkeytype/schemas/shared";
|
||||
import Format from "../utils/format";
|
||||
import { getHtmlByUserFlags } from "../controllers/user-flag-controller";
|
||||
import { SortedTable } from "../utils/sorted-table";
|
||||
import { getAvatarElement } from "../utils/discord-avatar";
|
||||
import { formatTypingStatsRatio } from "../utils/misc";
|
||||
import { getLanguageDisplayString } from "../utils/strings";
|
||||
import * as DB from "../db";
|
||||
import { getAuthenticatedUser } from "../firebase";
|
||||
import * as ServerConfiguration from "../ape/server-configuration";
|
||||
import * as AuthEvent from "../observables/auth-event";
|
||||
import { Connection } from "@monkeytype/schemas/connections";
|
||||
import { Friend } from "@monkeytype/schemas/users";
|
||||
import * as Loader from "../elements/loader";
|
||||
|
||||
const pageElement = $(".page.pageFriends");
|
||||
|
||||
let friendsTable: SortedTable<Friend> | undefined = undefined;
|
||||
|
||||
let pendingRequests: Connection[] | undefined;
|
||||
let friendsList: Friend[] | undefined;
|
||||
|
||||
export function getReceiverUid(
|
||||
connection: Pick<Connection, "initiatorUid" | "receiverUid">
|
||||
): string {
|
||||
const me = getAuthenticatedUser();
|
||||
if (me === null)
|
||||
throw new Error("expected to be authenticated in getReceiverUid");
|
||||
|
||||
if (me.uid === connection.initiatorUid) return connection.receiverUid;
|
||||
return connection.initiatorUid;
|
||||
}
|
||||
|
||||
export async function addFriend(receiverName: string): Promise<true | string> {
|
||||
const result = await Ape.connections.create({ body: { receiverName } });
|
||||
|
||||
if (result.status !== 200) {
|
||||
return `Friend request failed: ${result.body.message}`;
|
||||
} else {
|
||||
const snapshot = DB.getSnapshot();
|
||||
if (snapshot !== undefined) {
|
||||
const receiverUid = getReceiverUid(result.body.data);
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
|
||||
snapshot.connections[receiverUid] = result.body.data.status;
|
||||
updatePendingConnections();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const addFriendModal = new SimpleModal({
|
||||
id: "addFriend",
|
||||
title: "Add a friend",
|
||||
inputs: [
|
||||
{
|
||||
placeholder: "user name",
|
||||
type: "text",
|
||||
initVal: "",
|
||||
validation: {
|
||||
isValid: async (name: string) => {
|
||||
const checkNameResponse = await Ape.users.getNameAvailability({
|
||||
params: { name: name },
|
||||
});
|
||||
|
||||
return (
|
||||
(checkNameResponse.status === 200 &&
|
||||
!checkNameResponse.body.data.available) ||
|
||||
"Unknown user"
|
||||
);
|
||||
},
|
||||
debounceDelay: 1000,
|
||||
},
|
||||
},
|
||||
],
|
||||
buttonText: "request",
|
||||
onlineOnly: true,
|
||||
execFn: async (_thisPopup, receiverName) => {
|
||||
const result = await addFriend(receiverName);
|
||||
|
||||
if (result === true) {
|
||||
return { status: 1, message: `Request send to ${receiverName}` };
|
||||
}
|
||||
|
||||
let status: -1 | 0 | 1 = -1;
|
||||
let message: string = "Unknown error";
|
||||
|
||||
if (result.includes("already exists")) {
|
||||
status = 0;
|
||||
message = `You are already friends with ${receiverName}`;
|
||||
} else if (result.includes("request already sent")) {
|
||||
status = 0;
|
||||
message = `You have already sent a friend request to ${receiverName}`;
|
||||
} else if (result.includes("blocked by initiator")) {
|
||||
status = 0;
|
||||
message = `You have blocked ${receiverName}`;
|
||||
} else if (result.includes("blocked by receiver")) {
|
||||
status = 0;
|
||||
message = `${receiverName} has blocked you`;
|
||||
}
|
||||
|
||||
return { status, message, alwaysHide: true };
|
||||
},
|
||||
});
|
||||
|
||||
const removeFriendModal = new SimpleModal({
|
||||
id: "confirmUnfriend",
|
||||
title: "Remove friend",
|
||||
buttonText: "remove friend",
|
||||
text: "Are you sure you want to remove as a friend?",
|
||||
beforeInitFn: (thisPopup) => {
|
||||
thisPopup.text = `Are you sure you want to remove ${thisPopup.parameters[1]} as a friend?`;
|
||||
},
|
||||
execFn: async (thisPopup) => {
|
||||
const connectionId = thisPopup.parameters[0] as string;
|
||||
const result = await Ape.connections.delete({
|
||||
params: { id: connectionId },
|
||||
});
|
||||
if (result.status !== 200) {
|
||||
return { status: -1, message: result.body.message };
|
||||
} else {
|
||||
friendsList = friendsList?.filter(
|
||||
(it) => it.connectionId !== connectionId
|
||||
);
|
||||
friendsTable?.setData(friendsList ?? []);
|
||||
friendsTable?.updateBody();
|
||||
return { status: 1, message: `Friend removed` };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
async function fetchPendingConnections(): Promise<void> {
|
||||
const result = await Ape.connections.get({
|
||||
query: { status: "pending", type: "incoming" },
|
||||
});
|
||||
|
||||
if (result.status !== 200) {
|
||||
Notifications.add("Error getting connections: " + result.body.message, -1);
|
||||
pendingRequests = undefined;
|
||||
} else {
|
||||
pendingRequests = result.body.data;
|
||||
DB.mergeConnections(pendingRequests);
|
||||
}
|
||||
}
|
||||
|
||||
function updatePendingConnections(): void {
|
||||
$(".pageFriends .pendingRequests").addClass("hidden");
|
||||
|
||||
if (pendingRequests === undefined || pendingRequests.length === 0) {
|
||||
$(".pageFriends .pendingRequests").addClass("hidden");
|
||||
} else {
|
||||
$(".pageFriends .pendingRequests").removeClass("hidden");
|
||||
|
||||
const html = pendingRequests
|
||||
.map(
|
||||
(item) => `<tr data-id="${
|
||||
item._id
|
||||
}" data-receiver-uid="${getReceiverUid(item)}">
|
||||
<td><a href="${location.origin}/profile/${
|
||||
item.initiatorUid
|
||||
}?isUid" router-link>${item.initiatorName}</a></td>
|
||||
<td>
|
||||
<span data-balloon-pos="up" aria-label="since ${format(
|
||||
item.lastModified,
|
||||
"dd MMM yyyy HH:mm"
|
||||
)}">
|
||||
${formatAge(item.lastModified)} ago
|
||||
<span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="accepted" aria-label="accept" data-balloon-pos="up">
|
||||
<i class="fas fa-check fa-fw"></i>
|
||||
</button>
|
||||
<button class="rejected" aria-label="reject" data-balloon-pos="up">
|
||||
<i class="fas fa-times fa-fw"></i>
|
||||
</button>
|
||||
<button class="blocked" aria-label="block" data-balloon-pos="up">
|
||||
<i class="fas fa-shield-alt fa-fw"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>`
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
$(".pageFriends .pendingRequests tbody").html(html);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFriends(): Promise<void> {
|
||||
const result = await Ape.users.getFriends();
|
||||
if (result.status !== 200) {
|
||||
Notifications.add("Error getting friends: " + result.body.message, -1);
|
||||
friendsList = undefined;
|
||||
} else {
|
||||
friendsList = result.body.data;
|
||||
}
|
||||
}
|
||||
|
||||
function updateFriends(): void {
|
||||
$(".pageFriends .friends .nodata").addClass("hidden");
|
||||
$(".pageFriends .friends table").addClass("hidden");
|
||||
|
||||
$(".pageFriends .friends .error").addClass("hidden");
|
||||
|
||||
if (friendsList === undefined || friendsList.length === 0) {
|
||||
$(".pageFriends .friends table").addClass("hidden");
|
||||
$(".pageFriends .friends .nodata").removeClass("hidden");
|
||||
} else {
|
||||
$(".pageFriends .friends table").removeClass("hidden");
|
||||
$(".pageFriends .friends .nodata").addClass("hidden");
|
||||
|
||||
if (friendsTable === undefined) {
|
||||
friendsTable = new SortedTable<Friend>({
|
||||
table: ".pageFriends .friends table",
|
||||
data: friendsList,
|
||||
buildRow: buildFriendRow,
|
||||
initialSort: { property: "name", descending: false },
|
||||
});
|
||||
} else {
|
||||
friendsTable.setData(friendsList);
|
||||
}
|
||||
friendsTable.updateBody();
|
||||
}
|
||||
}
|
||||
|
||||
function buildFriendRow(entry: Friend): HTMLTableRowElement {
|
||||
const xpDetails = getXpDetails(entry.xp ?? 0);
|
||||
const testStats = formatTypingStatsRatio(entry);
|
||||
|
||||
const top15 = formatPb(entry.top15);
|
||||
const top60 = formatPb(entry.top60);
|
||||
|
||||
const element = document.createElement("tr");
|
||||
element.dataset["connectionId"] = entry.connectionId;
|
||||
|
||||
const isMe = entry.uid === getAuthenticatedUser()?.uid;
|
||||
|
||||
let actions = "";
|
||||
if (isMe) {
|
||||
element.classList.add("me");
|
||||
} else {
|
||||
actions = `<button class="remove">
|
||||
<i class="fas fa-user-times fa-fw"></i>
|
||||
</button>`;
|
||||
}
|
||||
element.innerHTML = `<tr>
|
||||
<td>
|
||||
<div class="avatarNameBadge">
|
||||
<div class="avatarPlaceholder"></div>
|
||||
<a href="${location.origin}/profile/${
|
||||
entry.uid
|
||||
}?isUid" class="entryName" uid=${entry.uid} router-link>${
|
||||
entry.name
|
||||
}</a> <div class="flagsAndBadge">
|
||||
${getHtmlByUserFlags(entry)}
|
||||
${
|
||||
isSafeNumber(entry.badgeId)
|
||||
? getBadgeHTMLbyId(entry.badgeId)
|
||||
: ""
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td><span data-balloon-pos="up" aria-label="${
|
||||
entry.lastModified !== undefined
|
||||
? "since " + format(entry.lastModified, "dd MMM yyyy HH:mm")
|
||||
: ""
|
||||
}">${
|
||||
entry.lastModified !== undefined
|
||||
? formatAge(entry.lastModified, "short")
|
||||
: "-"
|
||||
}</span></td>
|
||||
<td><span aria-label="total xp: ${
|
||||
isSafeNumber(entry.xp) ? formatXp(entry.xp) : ""
|
||||
}" data-balloon-pos="up">
|
||||
${xpDetails.level}
|
||||
</span></td>
|
||||
<td><span aria-label="${testStats.completedPercentage}% (${
|
||||
testStats.restartRatio
|
||||
} restarts per completed test)" data-balloon-pos="up">${
|
||||
entry.completedTests
|
||||
}/${entry.startedTests}</span></td>
|
||||
<td>${secondsToString(
|
||||
Math.round(entry.timeTyping ?? 0),
|
||||
true,
|
||||
true
|
||||
)}</td>
|
||||
<td><span aria-label="${formatStreak(
|
||||
entry.streak?.maxLength,
|
||||
"max streak"
|
||||
)}" data-balloon-pos="up">
|
||||
${formatStreak(entry.streak?.length)}
|
||||
</span></td>
|
||||
<td class="small"><span aria-label="${
|
||||
top15?.details
|
||||
}" data-balloon-pos="up" data-balloon-break="">${
|
||||
top15?.wpm ?? "-"
|
||||
}<div class="sub">${top15?.acc ?? "-"}</div><span></td>
|
||||
<td class="small"><span aria-label="${
|
||||
top60?.details
|
||||
}" data-balloon-pos="up" data-balloon-break="">${
|
||||
top60?.wpm ?? "-"
|
||||
}<div class="sub">${top60?.acc ?? "-"}</div></span></td>
|
||||
<td class="actions">
|
||||
${actions}
|
||||
|
||||
</td>
|
||||
</tr>`;
|
||||
|
||||
element
|
||||
.querySelector(".avatarPlaceholder")
|
||||
?.replaceWith(getAvatarElement(entry));
|
||||
return element;
|
||||
}
|
||||
|
||||
function formatAge(
|
||||
timestamp: number | undefined,
|
||||
format?: "short" | "full"
|
||||
): string {
|
||||
if (timestamp === undefined) return "";
|
||||
let formatted = "";
|
||||
const duration = intervalToDuration({ start: timestamp, end: Date.now() });
|
||||
|
||||
if (format === undefined || format === "full") {
|
||||
formatted = formatDuration(duration, {
|
||||
format: ["years", "months", "days", "hours", "minutes"],
|
||||
});
|
||||
} else {
|
||||
formatted = formatDistanceToNow(timestamp);
|
||||
}
|
||||
|
||||
return formatted !== "" ? formatted : "less then a minute";
|
||||
}
|
||||
|
||||
function formatPb(entry?: PersonalBest):
|
||||
| {
|
||||
wpm: string;
|
||||
acc: string;
|
||||
raw: string;
|
||||
con: string;
|
||||
details: string;
|
||||
}
|
||||
| undefined {
|
||||
if (entry === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
const result = {
|
||||
wpm: Format.typingSpeed(entry.wpm, { showDecimalPlaces: true }),
|
||||
acc: Format.percentage(entry.acc, { showDecimalPlaces: true }),
|
||||
raw: Format.typingSpeed(entry.raw, { showDecimalPlaces: true }),
|
||||
con: Format.percentage(entry.consistency, { showDecimalPlaces: true }),
|
||||
details: "",
|
||||
};
|
||||
|
||||
result.details = [
|
||||
`${getLanguageDisplayString(entry.language)}`,
|
||||
`${result.wpm} wpm`,
|
||||
`${result.acc} acc`,
|
||||
`${result.raw} raw`,
|
||||
`${result.con} con`,
|
||||
`${dateFormat(entry.timestamp, "dd MMM yyyy")}`,
|
||||
].join("\n");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function formatStreak(length?: number, prefix?: string): string {
|
||||
if (length === 1) return "-";
|
||||
return isSafeNumber(length)
|
||||
? `${prefix !== undefined ? prefix + " " : ""}${length} days`
|
||||
: "-";
|
||||
}
|
||||
|
||||
$(".pageFriends button.friendAdd").on("click", () => {
|
||||
addFriendModal.show(undefined, {});
|
||||
});
|
||||
|
||||
// need to set the listener for action buttons on the table because the table content is getting replaced
|
||||
$(".pageFriends .pendingRequests table").on("click", async (e) => {
|
||||
const action = Array.from(e.target.classList).find((it) =>
|
||||
["accepted", "rejected", "blocked"].includes(it)
|
||||
) as "accepted" | "rejected" | "blocked";
|
||||
|
||||
if (action === undefined) return;
|
||||
|
||||
const row = e.target.closest("tr") as HTMLElement;
|
||||
const id = row.dataset["id"];
|
||||
if (id === undefined) {
|
||||
throw new Error("Cannot find id of target.");
|
||||
}
|
||||
row.querySelectorAll("button").forEach((button) => (button.disabled = true));
|
||||
|
||||
Loader.show();
|
||||
const result =
|
||||
action === "rejected"
|
||||
? await Ape.connections.delete({
|
||||
params: { id },
|
||||
})
|
||||
: await Ape.connections.update({
|
||||
params: { id },
|
||||
body: { status: action },
|
||||
});
|
||||
Loader.hide();
|
||||
|
||||
if (result.status !== 200) {
|
||||
Notifications.add(
|
||||
`Cannot update friend request: ${result.body.message}`,
|
||||
-1
|
||||
);
|
||||
} else {
|
||||
//remove from cache
|
||||
pendingRequests = pendingRequests?.filter((it) => it._id !== id);
|
||||
updatePendingConnections();
|
||||
|
||||
const snapshot = DB.getSnapshot();
|
||||
if (snapshot) {
|
||||
const receiverUid = row.dataset["receiverUid"];
|
||||
if (receiverUid === undefined) {
|
||||
throw new Error("Cannot find receiverUid of target.");
|
||||
}
|
||||
|
||||
if (action === "rejected") {
|
||||
// eslint-disable-next-line @typescript-eslint/no-dynamic-delete, @typescript-eslint/no-unsafe-member-access
|
||||
delete snapshot.connections[receiverUid];
|
||||
} else {
|
||||
snapshot.connections[receiverUid] = action;
|
||||
}
|
||||
DB.setSnapshot(snapshot);
|
||||
}
|
||||
|
||||
if (action === "blocked") {
|
||||
Notifications.add(`User has been blocked`, 0);
|
||||
}
|
||||
if (action === "accepted") {
|
||||
Notifications.add(`Request accepted`, 1);
|
||||
}
|
||||
if (action === "rejected") {
|
||||
Notifications.add(`Request rejected`, 0);
|
||||
}
|
||||
|
||||
if (action === "accepted") {
|
||||
showSpinner();
|
||||
await fetchFriends();
|
||||
updateFriends();
|
||||
hideSpinner();
|
||||
}
|
||||
}
|
||||
});
|
||||
// need to set the listener for action buttons on the table because the table content is getting replaced
|
||||
$(".pageFriends .friends table").on("click", async (e) => {
|
||||
const action = Array.from(e.target.classList).find((it) =>
|
||||
["remove"].includes(it)
|
||||
);
|
||||
|
||||
if (action === undefined) return;
|
||||
|
||||
const row = e.target.closest("tr") as HTMLElement;
|
||||
const connectionId = row.dataset["connectionId"];
|
||||
if (connectionId === undefined) {
|
||||
throw new Error("Cannot find id of target.");
|
||||
}
|
||||
|
||||
if (action === "remove") {
|
||||
const name = row.querySelector("a.entryName")?.textContent ?? "";
|
||||
|
||||
removeFriendModal.show([connectionId, name], {});
|
||||
}
|
||||
});
|
||||
|
||||
function showSpinner(): void {
|
||||
document.querySelector(".friends .spinner")?.classList.remove("hidden");
|
||||
}
|
||||
|
||||
function hideSpinner(): void {
|
||||
document.querySelector(".friends .spinner")?.classList.add("hidden");
|
||||
}
|
||||
|
||||
function update(): void {
|
||||
updatePendingConnections();
|
||||
updateFriends();
|
||||
}
|
||||
|
||||
export const page = new Page<undefined>({
|
||||
id: "friends",
|
||||
display: "Friends",
|
||||
element: pageElement,
|
||||
path: "/friends",
|
||||
loadingOptions: {
|
||||
loadingMode: () => {
|
||||
if (!getAuthenticatedUser()) {
|
||||
return "none";
|
||||
}
|
||||
const hasCache =
|
||||
friendsList !== undefined && pendingRequests !== undefined;
|
||||
|
||||
if (hasCache) {
|
||||
return {
|
||||
mode: "async",
|
||||
beforeLoading: showSpinner,
|
||||
afterLoading: () => {
|
||||
hideSpinner();
|
||||
update();
|
||||
},
|
||||
};
|
||||
} else {
|
||||
return "sync";
|
||||
}
|
||||
},
|
||||
|
||||
loadingPromise: async () => {
|
||||
await ServerConfiguration.configurationPromise;
|
||||
const serverConfig = ServerConfiguration.get();
|
||||
if (!serverConfig?.connections.enabled) {
|
||||
throw new Error("Connectins are disabled.");
|
||||
}
|
||||
|
||||
await Promise.all([fetchPendingConnections(), fetchFriends()]);
|
||||
},
|
||||
style: "bar",
|
||||
keyframes: [
|
||||
{ percentage: 50, durationMs: 1500, text: "Downloading friends..." },
|
||||
{
|
||||
percentage: 50,
|
||||
durationMs: 1500,
|
||||
text: "Downloading friend requests...",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
afterHide: async (): Promise<void> => {
|
||||
Skeleton.remove("pageFriends");
|
||||
},
|
||||
beforeShow: async (): Promise<void> => {
|
||||
Skeleton.append("pageFriends", "main");
|
||||
update();
|
||||
},
|
||||
});
|
||||
|
||||
$(() => {
|
||||
Skeleton.save("pageFriends");
|
||||
});
|
||||
|
||||
AuthEvent.subscribe((event) => {
|
||||
if (event.type === "authStateChanged" && !event.data.isUserSignedIn) {
|
||||
pendingRequests = undefined;
|
||||
friendsList = undefined;
|
||||
}
|
||||
});
|
||||
|
|
@ -434,7 +434,10 @@ function buildTableRow(entry: LeaderboardEntry, me = false): HTMLElement {
|
|||
entry.uid
|
||||
}?isUid" class="entryName" uid=${entry.uid} router-link>${entry.name}</a>
|
||||
<div class="flagsAndBadge">
|
||||
${getHtmlByUserFlags(entry)}
|
||||
${getHtmlByUserFlags({
|
||||
...entry,
|
||||
isFriend: DB.isFriend(entry.uid),
|
||||
})}
|
||||
${
|
||||
isSafeNumber(entry.badgeId) ? getBadgeHTMLbyId(entry.badgeId) : ""
|
||||
}
|
||||
|
|
@ -489,7 +492,10 @@ function buildWeeklyTableRow(
|
|||
entry.uid
|
||||
}?isUid" class="entryName" uid=${entry.uid} router-link>${entry.name}</a>
|
||||
<div class="flagsAndBadge">
|
||||
${getHtmlByUserFlags(entry)}
|
||||
${getHtmlByUserFlags({
|
||||
...entry,
|
||||
isFriend: DB.isFriend(entry.uid),
|
||||
})}
|
||||
${
|
||||
isSafeNumber(entry.badgeId) ? getBadgeHTMLbyId(entry.badgeId) : ""
|
||||
}
|
||||
|
|
@ -1417,7 +1423,7 @@ export const page = new PageWithUrlParams({
|
|||
stopTimer();
|
||||
},
|
||||
beforeShow: async (options): Promise<void> => {
|
||||
await ServerConfiguration.configPromise;
|
||||
await ServerConfiguration.configurationPromise;
|
||||
Skeleton.append("pageLeaderboards", "main");
|
||||
await updateValidDailyLeaderboards();
|
||||
await appendModeAndLanguageButtons();
|
||||
|
|
|
|||
|
|
@ -74,13 +74,15 @@ const nameInputEl = document.querySelector(
|
|||
validateWithIndicator(nameInputEl, {
|
||||
schema: UserNameSchema,
|
||||
isValid: async (name: string) => {
|
||||
const checkNameResponse = (
|
||||
await Ape.users.getNameAvailability({
|
||||
params: { name: name },
|
||||
})
|
||||
).status;
|
||||
const checkNameResponse = await Ape.users.getNameAvailability({
|
||||
params: { name: name },
|
||||
});
|
||||
|
||||
return checkNameResponse === 200 ? true : "Name not available";
|
||||
return (
|
||||
(checkNameResponse.status === 200 &&
|
||||
checkNameResponse.body.data.available) ||
|
||||
"Name not available"
|
||||
);
|
||||
},
|
||||
debounceDelay: 1000,
|
||||
callback: (result) => {
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ export type PageName =
|
|||
| "profileSearch"
|
||||
| "404"
|
||||
| "accountSettings"
|
||||
| "leaderboards";
|
||||
| "leaderboards"
|
||||
| "friends";
|
||||
|
||||
type Options<T> = {
|
||||
params?: Record<string, string>;
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { PersonalBests } from "@monkeytype/schemas/shared";
|
|||
import * as TestActivity from "../elements/test-activity";
|
||||
import { TestActivityCalendar } from "../elements/test-activity-calendar";
|
||||
import { getFirstDayOfTheWeek } from "../utils/date-and-time";
|
||||
import { addFriend } from "./friends";
|
||||
|
||||
const firstDayOfTheWeek = getFirstDayOfTheWeek();
|
||||
|
||||
|
|
@ -74,12 +75,19 @@ function reset(): void {
|
|||
</div>
|
||||
<div class="buttonGroup">
|
||||
<button
|
||||
class="userReportButton"
|
||||
class="userReportButton hidden"
|
||||
data-balloon-pos="left"
|
||||
aria-label="Report user"
|
||||
>
|
||||
<i class="fas fa-flag"></i>
|
||||
</button>
|
||||
<button
|
||||
class="addFriendButton hidden"
|
||||
data-balloon-pos="left"
|
||||
aria-label="Send friend request"
|
||||
>
|
||||
<i class="fas fa-user-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="leaderboardsPositions">
|
||||
|
|
@ -237,6 +245,19 @@ $(".page.pageProfile").on("click", ".profile .userReportButton", () => {
|
|||
void UserReportModal.show({ uid, name, lbOptOut });
|
||||
});
|
||||
|
||||
$(".page.pageProfile").on("click", ".profile .addFriendButton", async () => {
|
||||
const friendName = $(".page.pageProfile .profile").attr("name") ?? "";
|
||||
|
||||
const result = await addFriend(friendName);
|
||||
|
||||
if (result === true) {
|
||||
Notifications.add(`Request send to ${friendName}`);
|
||||
$(".profile .details .addFriendButton").addClass("disabled");
|
||||
} else {
|
||||
Notifications.add(result, -1);
|
||||
}
|
||||
});
|
||||
|
||||
export const page = new Page<undefined | UserProfile>({
|
||||
id: "profile",
|
||||
element: $(".page.pageProfile"),
|
||||
|
|
|
|||
|
|
@ -36,6 +36,9 @@ $(async (): Promise<void> => {
|
|||
$(".login").addClass("hidden");
|
||||
$(".disabledNotification").removeClass("hidden");
|
||||
}
|
||||
if (!ServerConfiguration.get()?.connections.enabled) {
|
||||
$(".accountButtonAndMenu .goToFriends").addClass("hidden");
|
||||
}
|
||||
});
|
||||
}
|
||||
MonkeyPower.init();
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { abbreviateNumber } from "./numbers";
|
||||
|
||||
/**
|
||||
* Calculates the level based on the total XP.
|
||||
* This is the inverse of the function getTotalXpToReachLevel()
|
||||
|
|
@ -51,3 +53,11 @@ export function getXpDetails(totalXp: number): XPDetails {
|
|||
levelMaxXp: getLevelMaxXp(level),
|
||||
};
|
||||
}
|
||||
|
||||
export function formatXp(xp: number): string {
|
||||
if (xp < 1000) {
|
||||
return Math.round(xp).toString();
|
||||
} else {
|
||||
return abbreviateNumber(xp);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import { lastElementFromArray } from "./arrays";
|
|||
import { Config } from "@monkeytype/schemas/configs";
|
||||
import { Mode, Mode2, PersonalBests } from "@monkeytype/schemas/shared";
|
||||
import { Result } from "@monkeytype/schemas/results";
|
||||
import { RankAndCount } from "@monkeytype/schemas/users";
|
||||
import { roundTo2 } from "@monkeytype/util/numbers";
|
||||
|
||||
export function whorf(speed: number, wordlen: number): number {
|
||||
return Math.min(
|
||||
|
|
@ -761,6 +763,33 @@ export function scrollToCenterOrTop(el: HTMLElement | null): void {
|
|||
});
|
||||
}
|
||||
|
||||
export function formatTopPercentage(lbRank: RankAndCount): string {
|
||||
if (lbRank.rank === undefined) return "-";
|
||||
if (lbRank.rank === 1) return "GOAT";
|
||||
return "Top " + roundTo2((lbRank.rank / lbRank.count) * 100) + "%";
|
||||
}
|
||||
|
||||
export function formatTypingStatsRatio(stats: {
|
||||
startedTests?: number;
|
||||
completedTests?: number;
|
||||
}): {
|
||||
completedPercentage: string;
|
||||
restartRatio: string;
|
||||
} {
|
||||
if (stats.completedTests === undefined || stats.startedTests === undefined) {
|
||||
return { completedPercentage: "", restartRatio: "" };
|
||||
}
|
||||
return {
|
||||
completedPercentage: Math.floor(
|
||||
(stats.completedTests / stats.startedTests) * 100
|
||||
).toString(),
|
||||
restartRatio: (
|
||||
(stats.startedTests - stats.completedTests) /
|
||||
stats.completedTests
|
||||
).toFixed(1),
|
||||
};
|
||||
}
|
||||
|
||||
export function addToGlobal(items: Record<string, unknown>): void {
|
||||
for (const [name, item] of Object.entries(items)) {
|
||||
//@ts-expect-error dev
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ export type ExecReturn = {
|
|||
notificationOptions?: Notifications.AddNotificationOptions;
|
||||
hideOptions?: HideOptions;
|
||||
afterHide?: () => void;
|
||||
alwaysHide?: boolean;
|
||||
};
|
||||
|
||||
type FormInput = CommonInputType & {
|
||||
|
|
@ -373,7 +374,7 @@ export class SimpleModal {
|
|||
if (res.showNotification ?? true) {
|
||||
Notifications.add(res.message, res.status, res.notificationOptions);
|
||||
}
|
||||
if (res.status === 1) {
|
||||
if (res.status === 1 || res.alwaysHide) {
|
||||
void this.hide(true, res.hideOptions).then(() => {
|
||||
if (res.afterHide) {
|
||||
res.afterHide();
|
||||
|
|
|
|||
145
frontend/src/ts/utils/sorted-table.ts
Normal file
145
frontend/src/ts/utils/sorted-table.ts
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
type Sort = {
|
||||
property: string;
|
||||
descending: boolean;
|
||||
};
|
||||
|
||||
type SortedTableOptions<T> = {
|
||||
table: string;
|
||||
data?: T[];
|
||||
buildRow: (entry: T) => HTMLTableRowElement;
|
||||
initialSort?: Sort;
|
||||
};
|
||||
export class SortedTable<T> {
|
||||
protected data: { source: T; element?: HTMLTableRowElement }[];
|
||||
private table: JQuery<HTMLTableElement>;
|
||||
private buildRow: (entry: T) => HTMLTableRowElement;
|
||||
private sort?: Sort;
|
||||
|
||||
constructor({ table, data, buildRow, initialSort }: SortedTableOptions<T>) {
|
||||
this.table = $(table);
|
||||
if (this.table === undefined) {
|
||||
throw new Error(`No element found for ${table}`);
|
||||
}
|
||||
|
||||
this.buildRow = buildRow;
|
||||
this.data = [];
|
||||
if (data !== undefined) {
|
||||
this.setData(data);
|
||||
}
|
||||
|
||||
if (initialSort !== undefined) {
|
||||
this.sort = initialSort;
|
||||
this.doSort();
|
||||
}
|
||||
|
||||
//init headers
|
||||
for (const col of this.table.find(`td[data-sort-property]`)) {
|
||||
col.classList.add("sortable");
|
||||
col.setAttribute("type", "button");
|
||||
col.onclick = (e: MouseEvent) => {
|
||||
const target = e.currentTarget as HTMLElement;
|
||||
const property = target.dataset["sortProperty"] as string;
|
||||
const defaultDirection =
|
||||
target.dataset["sortDefaultDirection"] === "desc";
|
||||
if (property === undefined) return;
|
||||
|
||||
if (this.sort === undefined || property !== this.sort.property) {
|
||||
this.sort = { property, descending: defaultDirection };
|
||||
} else {
|
||||
this.sort.descending = !this.sort?.descending;
|
||||
}
|
||||
|
||||
this.doSort();
|
||||
this.updateBody();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public setSort(sort: Partial<Sort>): void {
|
||||
this.sort = { ...this.sort, ...sort } as Sort;
|
||||
this.doSort();
|
||||
}
|
||||
|
||||
public setData(data: T[]): void {
|
||||
this.data = data.map((source) => ({ source }));
|
||||
this.doSort();
|
||||
}
|
||||
|
||||
private doSort(): void {
|
||||
if (this.sort === undefined) return;
|
||||
|
||||
const { property, descending } = this.sort;
|
||||
// Removes styling from previous sorting requests:
|
||||
this.table.find("thead td").removeClass("headerSorted");
|
||||
this.table.find("thead td").children("i").remove();
|
||||
this.table
|
||||
.find(`thead td[data-sort-property="${property}"]`)
|
||||
.addClass("headerSorted")
|
||||
.append(
|
||||
`<i class="fas ${
|
||||
descending ? "fa-sort-down" : "fa-sort-up"
|
||||
} aria-hidden="true"></i>`
|
||||
);
|
||||
|
||||
this.data.sort((a, b) => {
|
||||
const valA = getValueByPath(a.source, property);
|
||||
const valB = getValueByPath(b.source, property);
|
||||
|
||||
let result = 0;
|
||||
|
||||
if (valA === undefined && valB !== undefined) {
|
||||
return descending ? 1 : -1;
|
||||
} else if (valA !== undefined && valB === undefined) {
|
||||
return descending ? -1 : 1;
|
||||
}
|
||||
|
||||
if (typeof valA === "string" && typeof valB === "string") {
|
||||
result = valA.localeCompare(valB);
|
||||
}
|
||||
|
||||
if (typeof valA === "number" && typeof valB === "number") {
|
||||
result = valA - valB;
|
||||
}
|
||||
|
||||
return descending ? -result : result;
|
||||
});
|
||||
}
|
||||
public updateBody(): void {
|
||||
const body = this.table.find("tbody");
|
||||
body.empty();
|
||||
body.append(
|
||||
this.getData().map((data) => {
|
||||
if (data.element === undefined) {
|
||||
data.element = this.buildRow(data.source);
|
||||
}
|
||||
return data.element;
|
||||
})
|
||||
);
|
||||
}
|
||||
protected getData(): { source: T; element?: HTMLTableRowElement }[] {
|
||||
return this.data;
|
||||
}
|
||||
}
|
||||
|
||||
function getValueByPath(obj: unknown, path: string): unknown {
|
||||
return path.split(".").reduce((acc, key) => {
|
||||
// oxlint-disable-next-line no-explicit-any
|
||||
// @ts-expect-error this is fine
|
||||
return acc !== null && acc !== undefined ? acc[key] : undefined;
|
||||
}, obj);
|
||||
}
|
||||
|
||||
export class SortedTableWithLimit<T> extends SortedTable<T> {
|
||||
private limit: number;
|
||||
constructor(options: SortedTableOptions<T> & { limit: number }) {
|
||||
super(options);
|
||||
this.limit = options.limit;
|
||||
}
|
||||
protected override getData(): { source: T; element?: HTMLTableRowElement }[] {
|
||||
return this.data.slice(0, this.limit);
|
||||
}
|
||||
|
||||
public setLimit(limit: number): void {
|
||||
this.limit = limit;
|
||||
}
|
||||
}
|
||||
136
packages/contracts/src/connections.ts
Normal file
136
packages/contracts/src/connections.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
import { initContract } from "@ts-rest/core";
|
||||
|
||||
import {
|
||||
ConnectionSchema,
|
||||
ConnectionStatusSchema,
|
||||
ConnectionTypeSchema,
|
||||
} from "@monkeytype/schemas/connections";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
CommonResponses,
|
||||
meta,
|
||||
MonkeyResponseSchema,
|
||||
responseWithData,
|
||||
} from "./util/api";
|
||||
import { IdSchema } from "@monkeytype/schemas/util";
|
||||
|
||||
const c = initContract();
|
||||
|
||||
export const GetConnectionsResponseSchema = responseWithData(
|
||||
z.array(ConnectionSchema)
|
||||
);
|
||||
export type GetConnectionsResponse = z.infer<
|
||||
typeof GetConnectionsResponseSchema
|
||||
>;
|
||||
|
||||
export const GetConnectionsQuerySchema = z.object({
|
||||
status: z
|
||||
.array(ConnectionStatusSchema)
|
||||
.or(ConnectionStatusSchema.transform((it) => [it]))
|
||||
.optional(),
|
||||
type: z
|
||||
.array(ConnectionTypeSchema)
|
||||
.or(ConnectionTypeSchema.transform((it) => [it]))
|
||||
.optional(),
|
||||
});
|
||||
export type GetConnectionsQuery = z.infer<typeof GetConnectionsQuerySchema>;
|
||||
|
||||
export const CreateConnectionRequestSchema = ConnectionSchema.pick({
|
||||
receiverName: true,
|
||||
});
|
||||
export type CreateConnectionRequest = z.infer<
|
||||
typeof CreateConnectionRequestSchema
|
||||
>;
|
||||
|
||||
export const CreateConnectionResponseSchema =
|
||||
responseWithData(ConnectionSchema);
|
||||
export type CreateConnectionResponse = z.infer<
|
||||
typeof CreateConnectionResponseSchema
|
||||
>;
|
||||
|
||||
export const IdPathParamsSchema = z.object({
|
||||
id: IdSchema,
|
||||
});
|
||||
export type IdPathParams = z.infer<typeof IdPathParamsSchema>;
|
||||
|
||||
export const UpdateConnectionRequestSchema = z.object({
|
||||
status: ConnectionStatusSchema.exclude(["pending"]),
|
||||
});
|
||||
export type UpdateConnectionRequest = z.infer<
|
||||
typeof UpdateConnectionRequestSchema
|
||||
>;
|
||||
|
||||
export const connectionsContract = c.router(
|
||||
{
|
||||
get: {
|
||||
summary: "get connections",
|
||||
description: "Get connections of the current user",
|
||||
method: "GET",
|
||||
path: "/",
|
||||
query: GetConnectionsQuerySchema.strict(),
|
||||
responses: {
|
||||
200: GetConnectionsResponseSchema,
|
||||
},
|
||||
metadata: meta({
|
||||
rateLimit: "connectionGet",
|
||||
}),
|
||||
},
|
||||
create: {
|
||||
summary: "create connection",
|
||||
description: "Request a connection to a user ",
|
||||
method: "POST",
|
||||
path: "/",
|
||||
body: CreateConnectionRequestSchema.strict(),
|
||||
responses: {
|
||||
200: CreateConnectionResponseSchema,
|
||||
404: MonkeyResponseSchema.describe("ReceiverUid unknown"),
|
||||
409: MonkeyResponseSchema.describe(
|
||||
"Duplicate connection, blocked or max connections reached"
|
||||
),
|
||||
},
|
||||
metadata: meta({
|
||||
rateLimit: "connectionCreate",
|
||||
}),
|
||||
},
|
||||
delete: {
|
||||
summary: "delete connection",
|
||||
description: "Delete a connection",
|
||||
method: "DELETE",
|
||||
path: "/:id",
|
||||
pathParams: IdPathParamsSchema.strict(),
|
||||
body: c.noBody(),
|
||||
responses: {
|
||||
200: MonkeyResponseSchema,
|
||||
},
|
||||
metadata: meta({
|
||||
rateLimit: "connectionDelete",
|
||||
}),
|
||||
},
|
||||
update: {
|
||||
summary: "update connection",
|
||||
description: "Update a connection status",
|
||||
method: "PATCH",
|
||||
path: "/:id",
|
||||
pathParams: IdPathParamsSchema.strict(),
|
||||
body: UpdateConnectionRequestSchema.strict(),
|
||||
responses: {
|
||||
200: MonkeyResponseSchema,
|
||||
},
|
||||
metadata: meta({
|
||||
rateLimit: "connectionUpdate",
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
pathPrefix: "/connections",
|
||||
strictStatusCodes: true,
|
||||
metadata: meta({
|
||||
openApiTags: "connections",
|
||||
requireConfiguration: {
|
||||
path: "connections.enabled",
|
||||
invalidMessage: "Connections are not available at this time.",
|
||||
},
|
||||
}),
|
||||
commonResponses: CommonResponses,
|
||||
}
|
||||
);
|
||||
|
|
@ -12,6 +12,7 @@ import { devContract } from "./dev";
|
|||
import { usersContract } from "./users";
|
||||
import { quotesContract } from "./quotes";
|
||||
import { webhooksContract } from "./webhooks";
|
||||
import { connectionsContract } from "./connections";
|
||||
|
||||
const c = initContract();
|
||||
|
||||
|
|
@ -29,11 +30,12 @@ export const contract = c.router({
|
|||
users: usersContract,
|
||||
quotes: quotesContract,
|
||||
webhooks: webhooksContract,
|
||||
connections: connectionsContract,
|
||||
});
|
||||
|
||||
/**
|
||||
* Whenever there is a breaking change with old frontend clients increase this number.
|
||||
* This will inform the frontend to refresh.
|
||||
*/
|
||||
export const COMPATIBILITY_CHECK = 3;
|
||||
export const COMPATIBILITY_CHECK = 4;
|
||||
export const COMPATIBILITY_CHECK_HEADER = "X-Compatibility-Check";
|
||||
|
|
|
|||
|
|
@ -346,6 +346,11 @@ export const limits = {
|
|||
max: 60,
|
||||
},
|
||||
|
||||
userFriendGet: {
|
||||
window: "hour",
|
||||
max: 60,
|
||||
},
|
||||
|
||||
// ApeKeys Routing
|
||||
apeKeysGet: {
|
||||
window: "hour",
|
||||
|
|
@ -361,6 +366,26 @@ export const limits = {
|
|||
window: "second",
|
||||
max: 1,
|
||||
},
|
||||
|
||||
connectionGet: {
|
||||
window: "hour",
|
||||
max: 60,
|
||||
},
|
||||
|
||||
connectionCreate: {
|
||||
window: "hour",
|
||||
max: 60,
|
||||
},
|
||||
|
||||
connectionDelete: {
|
||||
window: "hour",
|
||||
max: 60,
|
||||
},
|
||||
|
||||
connectionUpdate: {
|
||||
window: "hour",
|
||||
max: 60,
|
||||
},
|
||||
} satisfies Record<string, RateLimitOptions>;
|
||||
|
||||
export type RateLimiterId = keyof typeof limits;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ import {
|
|||
UserTagSchema,
|
||||
UserEmailSchema,
|
||||
UserNameSchema,
|
||||
FriendSchema,
|
||||
} from "@monkeytype/schemas/users";
|
||||
import {
|
||||
Mode2Schema,
|
||||
|
|
@ -58,6 +59,13 @@ export type CheckNamePathParameters = z.infer<
|
|||
typeof CheckNamePathParametersSchema
|
||||
>;
|
||||
|
||||
export const CheckNameResponseSchema = responseWithData(
|
||||
z.object({
|
||||
available: z.boolean(),
|
||||
})
|
||||
);
|
||||
export type CheckNameResponse = z.infer<typeof CheckNameResponseSchema>;
|
||||
|
||||
export const UpdateUserNameRequestSchema = z.object({
|
||||
name: UserNameSchema,
|
||||
});
|
||||
|
|
@ -320,6 +328,9 @@ export const GetStreakResponseSchema =
|
|||
responseWithNullableData(UserStreakSchema);
|
||||
export type GetStreakResponse = z.infer<typeof GetStreakResponseSchema>;
|
||||
|
||||
export const GetFriendsResponseSchema = responseWithData(z.array(FriendSchema));
|
||||
export type GetFriendsResponse = z.infer<typeof GetFriendsResponseSchema>;
|
||||
|
||||
const c = initContract();
|
||||
|
||||
export const usersContract = c.router(
|
||||
|
|
@ -360,8 +371,7 @@ export const usersContract = c.router(
|
|||
path: "/checkName/:name",
|
||||
pathParams: CheckNamePathParametersSchema.strict(),
|
||||
responses: {
|
||||
200: MonkeyResponseSchema.describe("Name is available"),
|
||||
409: MonkeyResponseSchema.describe("Name is not available"),
|
||||
200: CheckNameResponseSchema,
|
||||
},
|
||||
metadata: meta({
|
||||
authenticationOptions: { isPublic: true },
|
||||
|
|
@ -926,6 +936,22 @@ export const usersContract = c.router(
|
|||
rateLimit: "userStreak",
|
||||
}),
|
||||
},
|
||||
getFriends: {
|
||||
summary: "get friends",
|
||||
description: "get friends list",
|
||||
method: "GET",
|
||||
path: "/friends",
|
||||
responses: {
|
||||
200: GetFriendsResponseSchema,
|
||||
},
|
||||
metadata: meta({
|
||||
rateLimit: "userFriendGet",
|
||||
requireConfiguration: {
|
||||
path: "connections.enabled",
|
||||
invalidMessage: "Connections are not available at this time.",
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
pathPrefix: "/users",
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ export type OpenApiTag =
|
|||
| "development"
|
||||
| "users"
|
||||
| "quotes"
|
||||
| "webhooks";
|
||||
| "webhooks"
|
||||
| "connections";
|
||||
|
||||
export type PermissionId =
|
||||
| "quoteMod"
|
||||
|
|
|
|||
|
|
@ -123,5 +123,9 @@ export const ConfigurationSchema = z.object({
|
|||
xpRewardBrackets: z.array(RewardBracketSchema),
|
||||
}),
|
||||
}),
|
||||
connections: z.object({
|
||||
enabled: z.boolean(),
|
||||
maxPerUser: z.number().int().nonnegative(),
|
||||
}),
|
||||
});
|
||||
export type Configuration = z.infer<typeof ConfigurationSchema>;
|
||||
|
|
|
|||
24
packages/schemas/src/connections.ts
Normal file
24
packages/schemas/src/connections.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { z } from "zod";
|
||||
import { IdSchema } from "./util";
|
||||
|
||||
export const ConnectionStatusSchema = z.enum([
|
||||
"pending",
|
||||
"accepted",
|
||||
"blocked",
|
||||
]);
|
||||
export type ConnectionStatus = z.infer<typeof ConnectionStatusSchema>;
|
||||
|
||||
export const ConnectionTypeSchema = z.enum(["incoming", "outgoing"]);
|
||||
export type ConnectionType = z.infer<typeof ConnectionTypeSchema>;
|
||||
|
||||
export const ConnectionSchema = z.object({
|
||||
_id: IdSchema,
|
||||
initiatorUid: IdSchema,
|
||||
initiatorName: z.string(),
|
||||
receiverUid: IdSchema,
|
||||
receiverName: z.string(),
|
||||
lastModified: z.number().int().nonnegative(),
|
||||
status: ConnectionStatusSchema,
|
||||
});
|
||||
|
||||
export type Connection = z.infer<typeof ConnectionSchema>;
|
||||
|
|
@ -9,9 +9,11 @@ import {
|
|||
DefaultTimeModeSchema,
|
||||
QuoteLengthSchema,
|
||||
DifficultySchema,
|
||||
PersonalBestSchema,
|
||||
} from "./shared";
|
||||
import { CustomThemeColorsSchema, FunboxNameSchema } from "./configs";
|
||||
import { doesNotContainProfanity } from "./validation/validation";
|
||||
import { ConnectionSchema } from "./connections";
|
||||
|
||||
const NoneFilterSchema = z.literal("none");
|
||||
export const ResultFiltersSchema = z.object({
|
||||
|
|
@ -382,3 +384,27 @@ export const PasswordSchema = z
|
|||
message: "must contain at least one special character",
|
||||
});
|
||||
export type Password = z.infer<typeof PasswordSchema>;
|
||||
|
||||
export const FriendSchema = UserSchema.pick({
|
||||
uid: true,
|
||||
name: true,
|
||||
discordId: true,
|
||||
discordAvatar: true,
|
||||
startedTests: true,
|
||||
completedTests: true,
|
||||
timeTyping: true,
|
||||
xp: true,
|
||||
banned: true,
|
||||
lbOptOut: true,
|
||||
})
|
||||
.extend({
|
||||
connectionId: IdSchema.optional(),
|
||||
top15: PersonalBestSchema.optional(),
|
||||
top60: PersonalBestSchema.optional(),
|
||||
badgeId: z.number().int().optional(),
|
||||
isPremium: z.boolean().optional(),
|
||||
streak: UserStreakSchema.pick({ length: true, maxLength: true }),
|
||||
})
|
||||
.merge(ConnectionSchema.pick({ lastModified: true }).partial());
|
||||
|
||||
export type Friend = z.infer<typeof FriendSchema>;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue