mirror of
https://github.com/zkldi/Tachi.git
synced 2026-10-14 01:59:05 +03:00
feat: followers
This commit is contained in:
@@ -5,8 +5,8 @@ import { UserDocument } from "tachi-common";
|
||||
export default function DashboardActivity({ user }: { user: UserDocument }) {
|
||||
return (
|
||||
<>
|
||||
<div className="display-4 mb-4">Here's what you and your rivals have been up to!</div>
|
||||
<Activity url={`/users/${user.id}/activity?includeRivals=true`} />
|
||||
<div className="display-4 mb-4">Here's what's been happening.</div>
|
||||
<Activity url={`/users/${user.id}/activity?includeRivals=true&includeFollowers=true`} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ import { Game, Playtype, UserDocument } from "tachi-common";
|
||||
import { UGPTStatsReturn } from "types/api-returns";
|
||||
import { UserContext } from "context/UserContext";
|
||||
import useLUGPTSettings from "components/util/useLUGPTSettings";
|
||||
import { UserSettingsContext } from "context/UserSettingsContext";
|
||||
import FollowUserButton from "components/util/FollowUserButton";
|
||||
import ProfileBadges from "./ProfileBadges";
|
||||
import ProfilePicture from "./ProfilePicture";
|
||||
import RankingData from "./UGPTRankingData";
|
||||
@@ -29,6 +31,7 @@ export function UGPTHeaderBody({
|
||||
stats: UGPTStatsReturn;
|
||||
}) {
|
||||
const { settings, setSettings } = useLUGPTSettings();
|
||||
const { settings: userSettings } = useContext(UserSettingsContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -81,76 +84,92 @@ export function UGPTHeaderBody({
|
||||
/>
|
||||
</div>
|
||||
{/* if someone is logged in and they aren't the user they're viewing */}
|
||||
{/* give them the option to add them as a rival */}
|
||||
{settings && reqUser.id !== settings.userID && (
|
||||
<div className="col-12 w-100 d-flex justify-content-center mt-8">
|
||||
{settings.rivals.includes(reqUser.id) ? (
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
onClick={async () => {
|
||||
const newRivals = settings.rivals.filter((e) => e !== reqUser.id);
|
||||
{/* give them the option to add them as a rival or follow them */}
|
||||
{userSettings && reqUser.id !== userSettings.userID && (
|
||||
<div
|
||||
className="col-12 w-100 d-flex justify-content-center mt-8"
|
||||
style={{
|
||||
gap: "30px",
|
||||
}}
|
||||
>
|
||||
{/* has the logged in user played this game? */}
|
||||
{/* otherwise, they can't rival. */}
|
||||
{settings && (
|
||||
<>
|
||||
{settings.rivals.includes(reqUser.id) ? (
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
onClick={async () => {
|
||||
const newRivals = settings.rivals.filter(
|
||||
(e) => e !== reqUser.id
|
||||
);
|
||||
|
||||
const res = await APIFetchV1(
|
||||
`/users/${settings.userID}/games/${game}/${playtype}/rivals`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
rivalIDs: newRivals,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
const res = await APIFetchV1(
|
||||
`/users/${settings.userID}/games/${game}/${playtype}/rivals`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
rivalIDs: newRivals,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (res.success) {
|
||||
SendSuccessToast(
|
||||
`Removed ${reqUser.username} from your rivals.`
|
||||
);
|
||||
setSettings({
|
||||
...settings,
|
||||
rivals: newRivals,
|
||||
});
|
||||
} else {
|
||||
SendErrorToast(HumaniseError(res.description));
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove as Rival
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline-success"
|
||||
onClick={async () => {
|
||||
const newRivals = [...settings.rivals, reqUser.id];
|
||||
if (res.success) {
|
||||
SendSuccessToast(
|
||||
`Removed ${reqUser.username} from your rivals.`
|
||||
);
|
||||
setSettings({
|
||||
...settings,
|
||||
rivals: newRivals,
|
||||
});
|
||||
} else {
|
||||
SendErrorToast(HumaniseError(res.description));
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove as Rival
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline-success"
|
||||
onClick={async () => {
|
||||
const newRivals = [...settings.rivals, reqUser.id];
|
||||
|
||||
const res = await APIFetchV1(
|
||||
`/users/${settings.userID}/games/${game}/${playtype}/rivals`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
rivalIDs: newRivals,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
const res = await APIFetchV1(
|
||||
`/users/${settings.userID}/games/${game}/${playtype}/rivals`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
rivalIDs: newRivals,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (res.success) {
|
||||
SendSuccessToast(`Added ${reqUser.username} to your rivals!`);
|
||||
setSettings({
|
||||
...settings,
|
||||
rivals: newRivals,
|
||||
});
|
||||
} else {
|
||||
SendErrorToast(HumaniseError(res.description));
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add as Rival
|
||||
</Button>
|
||||
if (res.success) {
|
||||
SendSuccessToast(
|
||||
`Added ${reqUser.username} to your rivals!`
|
||||
);
|
||||
setSettings({
|
||||
...settings,
|
||||
rivals: newRivals,
|
||||
});
|
||||
} else {
|
||||
SendErrorToast(HumaniseError(res.description));
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add as Rival
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<FollowUserButton userToFollow={reqUser} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -12,6 +12,7 @@ import React, { useContext, useState } from "react";
|
||||
import { Button, Form, Modal } from "react-bootstrap";
|
||||
import { UserDocument } from "tachi-common";
|
||||
import { SetState } from "types/react";
|
||||
import FollowUserButton from "components/util/FollowUserButton";
|
||||
import ProfileBadges from "./ProfileBadges";
|
||||
import ProfilePicture from "./ProfilePicture";
|
||||
|
||||
@@ -40,6 +41,8 @@ export function UserHeaderBody({ reqUser }: { reqUser: UserDocument }) {
|
||||
);
|
||||
}
|
||||
|
||||
const { user: loggedInUser } = useContext(UserContext);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="col-12 col-lg-3">
|
||||
@@ -78,6 +81,14 @@ export function UserHeaderBody({ reqUser }: { reqUser: UserDocument }) {
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{loggedInUser && reqUser.id !== loggedInUser.id && (
|
||||
<div className="col-12 mt-8">
|
||||
<Divider />
|
||||
<div className="d-flex w-100 justify-content-center ">
|
||||
<FollowUserButton userToFollow={reqUser} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { APIFetchV1 } from "util/api";
|
||||
import { UserSettingsContext } from "context/UserSettingsContext";
|
||||
import React, { useContext } from "react";
|
||||
import { Button } from "react-bootstrap";
|
||||
import { UserDocument } from "tachi-common";
|
||||
import QuickTooltip from "components/layout/misc/QuickTooltip";
|
||||
|
||||
export default function FollowUserButton({ userToFollow }: { userToFollow: UserDocument }) {
|
||||
const { settings: userSettings, setSettings: setUserSettings } =
|
||||
useContext(UserSettingsContext);
|
||||
|
||||
if (!userSettings) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// can't follow yourself
|
||||
if (userSettings.userID === userToFollow.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (userSettings.following.includes(userToFollow.id)) {
|
||||
return (
|
||||
<Button
|
||||
variant="outline-danger"
|
||||
onClick={async () => {
|
||||
const res = await APIFetchV1(
|
||||
`/users/${userSettings.userID}/following/remove`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
userID: userToFollow.id,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
if (res.success) {
|
||||
const newFollowing = userSettings.following.filter(
|
||||
(e) => e !== userToFollow.id
|
||||
);
|
||||
|
||||
setUserSettings({
|
||||
...userSettings,
|
||||
following: newFollowing,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Unfollow
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<QuickTooltip tooltipContent="Following a user will mean you'll see their sessions and updates in your feed.">
|
||||
<Button
|
||||
variant="outline-success"
|
||||
onClick={async () => {
|
||||
const res = await APIFetchV1(
|
||||
`/users/${userSettings.userID}/following/add`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
userID: userToFollow.id,
|
||||
}),
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
true,
|
||||
true
|
||||
);
|
||||
|
||||
if (res.success) {
|
||||
const newFollowing = [...userSettings.following, userToFollow.id];
|
||||
|
||||
setUserSettings({
|
||||
...userSettings,
|
||||
following: newFollowing,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
Follow
|
||||
</Button>
|
||||
</QuickTooltip>
|
||||
);
|
||||
}
|
||||
@@ -90,6 +90,7 @@ export interface TachiServerConfig {
|
||||
BEATORAJA_QUEUE_SIZE: integer;
|
||||
MAX_GOAL_SUBSCRIPTIONS: integer;
|
||||
MAX_QUEST_SUBSCRIPTIONS: integer;
|
||||
MAX_FOLLOWING_AMOUNT: integer;
|
||||
OUR_URL: string;
|
||||
ALLOW_RUNNING_OFFLINE?: boolean;
|
||||
INVITE_CODE_CONFIG?: {
|
||||
@@ -168,6 +169,7 @@ const err = p(config, {
|
||||
BEATORAJA_QUEUE_SIZE: p.optional(p.gteInt(2)),
|
||||
MAX_GOAL_SUBSCRIPTIONS: p.optional(p.isPositiveInteger),
|
||||
MAX_QUEST_SUBSCRIPTIONS: p.optional(p.isPositiveInteger),
|
||||
MAX_FOLLOWING_AMOUNT: p.optional(p.isPositiveInteger),
|
||||
OUR_URL: "string",
|
||||
INVITE_CODE_CONFIG: p.optional({
|
||||
BATCH_SIZE: p.isPositiveInteger,
|
||||
@@ -231,6 +233,7 @@ tachiServerConfig.USC_QUEUE_SIZE ??= 3;
|
||||
tachiServerConfig.BEATORAJA_QUEUE_SIZE ??= 3;
|
||||
tachiServerConfig.MAX_GOAL_SUBSCRIPTIONS ??= 1_000;
|
||||
tachiServerConfig.MAX_QUEST_SUBSCRIPTIONS ??= 100;
|
||||
tachiServerConfig.MAX_FOLLOWING_AMOUNT ??= 1_000;
|
||||
tachiServerConfig.USE_EXTERNAL_SCORE_IMPORT_WORKER ??= false;
|
||||
|
||||
export const TachiConfig = tachiServerConfig.TACHI_CONFIG;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Router } from "express";
|
||||
import db from "external/mongo/db";
|
||||
import CreateLogCtx from "lib/logger/logger";
|
||||
import { ServerConfig } from "lib/setup/config";
|
||||
import p from "prudence";
|
||||
import prValidate from "server/middleware/prudence-validate";
|
||||
import { GetUser } from "utils/req-tachi-data";
|
||||
@@ -55,6 +56,13 @@ router.post("/add", prValidate({ userID: p.isPositiveInteger }), async (req, res
|
||||
|
||||
const { userID: toFollow } = req.safeBody as { userID: integer };
|
||||
|
||||
if (user.id === toFollow) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `Can't follow yourself. Bit self-indulgent!`,
|
||||
});
|
||||
}
|
||||
|
||||
const settings = await db["user-settings"].findOne({ userID: user.id });
|
||||
|
||||
if (!settings) {
|
||||
@@ -73,6 +81,13 @@ router.post("/add", prValidate({ userID: p.isPositiveInteger }), async (req, res
|
||||
});
|
||||
}
|
||||
|
||||
if (settings.following.length >= ServerConfig.MAX_FOLLOWING_AMOUNT) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
description: `You are following too many people. The max is ${ServerConfig.MAX_FOLLOWING_AMOUNT}.`,
|
||||
});
|
||||
}
|
||||
|
||||
const userToFollow = await GetUserWithID(toFollow);
|
||||
|
||||
if (!userToFollow) {
|
||||
|
||||
@@ -425,11 +425,13 @@ router.use(
|
||||
prValidate({
|
||||
startTime: "*string",
|
||||
includeRivals: p.optional(p.isIn("true", "false")),
|
||||
includeFollowers: p.optional(p.isIn("true", "false")),
|
||||
}),
|
||||
async (req, res) => {
|
||||
const qStartTime = req.query.startTime as string | undefined;
|
||||
|
||||
const includeRivals = req.query.includeRivals === "true";
|
||||
const includeFollowers = req.query.includeFollowers === "true";
|
||||
|
||||
const startTime = qStartTime ? Number(qStartTime) : null;
|
||||
|
||||
@@ -448,14 +450,30 @@ router.use(
|
||||
|
||||
const data: Partial<Record<IDStrings, unknown>> = {};
|
||||
|
||||
const settings = await db["user-settings"].findOne({ userID: user.id });
|
||||
|
||||
if (!settings) {
|
||||
logger.error(`User ${FormatUserDoc(user)} doesn't have any settings?`);
|
||||
return res.status(500).json({
|
||||
success: false,
|
||||
description: `This user has no settings.`,
|
||||
});
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
gpts.map(async (e) => {
|
||||
let userID: ActivityConstraint["userID"] = user.id;
|
||||
const userIDs: Array<integer> = [user.id];
|
||||
|
||||
if (includeRivals) {
|
||||
const rivalIDs = await GetRivalIDs(user.id, e.game, e.playtype);
|
||||
|
||||
userID = { $in: [user.id, ...rivalIDs] };
|
||||
userIDs.push(...rivalIDs);
|
||||
}
|
||||
|
||||
// n.b. it is intentional behaviour that you only get updates for
|
||||
// people you follow on games you play.
|
||||
if (includeFollowers) {
|
||||
userIDs.push(...settings.following);
|
||||
}
|
||||
|
||||
const activity = await GetRecentActivity(
|
||||
@@ -463,7 +481,7 @@ router.use(
|
||||
{
|
||||
game: e.game,
|
||||
playtype: e.playtype,
|
||||
userID,
|
||||
userID: { $in: userIDs },
|
||||
},
|
||||
30,
|
||||
startTime
|
||||
|
||||
Reference in New Issue
Block a user