This commit is contained in:
Kalle
2026-03-17 18:51:49 +02:00
parent f65b920d3c
commit 0122a6c3f0
50 changed files with 533 additions and 299 deletions

View File

@@ -20,7 +20,7 @@ import { useUser } from "~/features/auth/core/user";
import { useChatContext } from "~/features/chat/useChatContext";
import { FriendMenu } from "~/features/friends/components/FriendMenu";
import { SENDOUQ_ACTIVITY_LABEL } from "~/features/friends/friends-constants";
import { useLayoutSize } from "~/hooks/useWindowSize";
import { useLayoutSize } from "~/hooks/useMainContentWidth";
import type { RootLoaderData } from "~/root";
import {
EVENTS_PAGE,

View File

@@ -62,9 +62,12 @@ function RoomList({ onClose }: { onClose?: () => void }) {
const chatContext = useChatContext()!;
const { formatDateTime } = useTimeFormat();
const nonExpiredRooms = chatContext.rooms.filter(
(room) => room.expiresAt > Date.now(),
);
const nonExpiredRooms = chatContext.rooms
.filter((room) => room.expiresAt > Date.now())
.sort((a, b) => {
if (a.isObsolete !== b.isObsolete) return a.isObsolete ? 1 : -1;
return 0;
});
return (
<div className={styles.sidebar}>
@@ -81,7 +84,11 @@ function RoomList({ onClose }: { onClose?: () => void }) {
return (
<Button
key={room.chatCode}
className={clsx(sideNavStyles.listButton, styles.roomItem)}
className={clsx(
sideNavStyles.listButton,
styles.roomItem,
room.isObsolete ? "opaque" : null,
)}
onPress={() => {
chatContext.requestHistory(room.chatCode);
chatContext.setActiveRoom(room.chatCode);
@@ -100,6 +107,7 @@ function RoomList({ onClose }: { onClose?: () => void }) {
className={clsx(
sideNavStyles.listLinkTitle,
styles.roomName,
room.isObsolete ? "line-through" : null,
)}
>
{resolveDatePlaceholders(room.header, (d) =>
@@ -115,7 +123,7 @@ function RoomList({ onClose }: { onClose?: () => void }) {
{room.subtitle}
</span>
</div>
{unread > 0 ? (
{unread > 0 && !room.isObsolete ? (
<span className={styles.unreadBadge}>{unread}</span>
) : room.lastMessageTimestamp > 0 ? (
<span className={styles.roomTimestamp}>
@@ -170,7 +178,12 @@ function ChatView({ onClose }: { onClose?: () => void }) {
/>
) : null}
<div className={styles.chatHeaderInfo}>
<span className={styles.chatHeaderTitle}>
<span
className={clsx(
styles.chatHeaderTitle,
room?.isObsolete ? "line-through" : null,
)}
>
{resolveDatePlaceholders(room?.header ?? activeRoom, (d) =>
formatDateTime(d, {
month: "short",

View File

@@ -202,6 +202,12 @@ export function Layout({
return () => window.removeEventListener("resize", handleResize);
}, []);
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally close modals on navigation
React.useEffect(() => {
setSideNavModalOpen(false);
setChatSidebarModalOpen(false);
}, [location.pathname]);
const user = useUser();
const { unseenIds } = useNotifications();
const sidebarData = data?.sidebar;
@@ -316,7 +322,6 @@ export function Layout({
<SiteLogoContent />
</Link>
<DialogTrigger
key={location.pathname}
isOpen={sideNavModalOpen}
onOpenChange={setSideNavModalOpen}
>

View File

@@ -56,7 +56,6 @@ import { modesShort, rankedModesShort } from "~/modules/in-game-lists/modes";
import { stagesObj as s, stageIds } from "~/modules/in-game-lists/stage-ids";
import type {
AbilityType,
MainWeaponId,
ModeShort,
StageId,
} from "~/modules/in-game-lists/types";
@@ -220,7 +219,6 @@ const basicSeeds = (variation?: SeedVariation | null) => [
calendarEventWithToToolsTeamsDepths,
calendarEventWithToToolsLUTI,
calendarEventWithToToolsTeamsLUTI,
tournamentSubs,
variation === "NO_TOURNAMENT_TEAMS" ? undefined : tournamentLfgGroups,
adminBuilds,
manySplattershotBuilds,
@@ -1565,80 +1563,19 @@ function calendarEventWithToToolsTeams(
}
}
function tournamentSubs() {
for (let id = 100; id < 120; id++) {
const includedWeaponIds: MainWeaponId[] = [];
sql
.prepare(
/* sql */ `
insert into "TournamentSub" (
"userId",
"tournamentId",
"canVc",
"bestWeapons",
"okWeapons",
"message",
"visibility"
) values (
@userId,
@tournamentId,
@canVc,
@bestWeapons,
@okWeapons,
@message,
@visibility
)
`,
)
.run({
userId: id,
tournamentId: 1,
canVc: Number(faker.number.float(1) > 0.5),
bestWeapons: nullFilledArray(
faker.helpers.arrayElement([1, 1, 1, 2, 2, 3, 4, 5]),
)
// biome-ignore lint/suspicious/useIterableCallbackReturn: Biome 2.3.1 upgrade
.map(() => {
while (true) {
const weaponId = R.sample(mainWeaponIds, 1)[0]!;
if (!includedWeaponIds.includes(weaponId)) {
includedWeaponIds.push(weaponId);
return weaponId;
}
}
})
.join(","),
okWeapons:
faker.number.float(1) > 0.5
? null
: nullFilledArray(
faker.helpers.arrayElement([1, 1, 1, 2, 2, 3, 4, 5]),
)
// biome-ignore lint/suspicious/useIterableCallbackReturn: Biome 2.3.1 upgrade
.map(() => {
while (true) {
const weaponId = R.sample(mainWeaponIds, 1)[0]!;
if (!includedWeaponIds.includes(weaponId)) {
includedWeaponIds.push(weaponId);
return weaponId;
}
}
})
.join(","),
message: faker.number.float(1) > 0.5 ? null : faker.lorem.paragraph(),
visibility: id < 105 ? "+1" : id < 110 ? "+2" : id < 115 ? "+2" : "ALL",
});
}
return null;
}
async function tournamentLfgGroups() {
const availableUsers = userIdsInAscendingOrderById().slice(300);
const MAX_GROUP_SIZE = 6;
// Add admin's friends to tournament LFG so sidebar shows tournament friends
for (const friendId of SENDOU_FRIEND_IDS_IN_TOURNAMENT_LFG) {
await TournamentLFGRepository.createPlaceholderTeam({
tournamentId: 1,
userId: friendId,
});
}
const tournaments = [1, 2, 3];
let userIndex = 0;
@@ -2895,12 +2832,14 @@ async function organization() {
}
const SENDOU_FRIEND_IDS_IN_LOOKING_GROUPS = [150, 151, 152, 153];
const SENDOU_FRIEND_IDS_AS_TOURNAMENT_SUBS = [100, 101, 102, 103];
const SENDOU_FRIEND_IDS_IN_TOURNAMENT_LFG = [100, 101];
const SENDOU_FRIEND_IDS_OTHER = [102, 103];
async function friendships(variation?: SeedVariation | null) {
const allFriendIds = [
...SENDOU_FRIEND_IDS_IN_LOOKING_GROUPS,
...SENDOU_FRIEND_IDS_AS_TOURNAMENT_SUBS,
...SENDOU_FRIEND_IDS_IN_TOURNAMENT_LFG,
...SENDOU_FRIEND_IDS_OTHER,
];
for (const friendId of allFriendIds) {

View File

@@ -3,6 +3,7 @@ import { db } from "~/db/sql";
import type { TablesInsertable } from "~/db/tables";
import type { AssociationVirtualIdentifier } from "~/features/associations/associations-constants";
import { ASSOCIATION } from "~/features/associations/associations-constants";
import * as FriendRepository from "~/features/friends/FriendRepository.server";
import { LimitReachedError } from "~/utils/errors";
import { shortNanoid } from "~/utils/id";
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
@@ -28,6 +29,7 @@ export async function findByMemberUserId(
return {
actual: await findBy({ type: "user", userId }, options),
virtual: await virtualAssociationsByUserId(userId),
friendIds: await FriendRepository.findFriendIds(userId),
};
}
@@ -94,6 +96,10 @@ async function findBy(
}));
}
const DEFAULT_VIRTUAL_ASSOCIATIONS: Array<AssociationVirtualIdentifier> = [
"FRIENDS",
];
async function virtualAssociationsByUserId(
userId: number,
): Promise<Array<AssociationVirtualIdentifier>> {
@@ -103,14 +109,16 @@ async function virtualAssociationsByUserId(
.select(["PlusTier.tier as plusTier"])
.where("userId", "=", userId)
.executeTakeFirst()) ?? {};
if (!plusTier) return [];
if (!plusTier) return [...DEFAULT_VIRTUAL_ASSOCIATIONS];
if (plusTier === 1) return ["+1", "+2", "+3"] as const;
if (plusTier === 2) return ["+2", "+3"] as const;
if (plusTier === 3) return ["+3"] as const;
if (plusTier === 1)
return [...DEFAULT_VIRTUAL_ASSOCIATIONS, "+1", "+2", "+3"] as const;
if (plusTier === 2)
return [...DEFAULT_VIRTUAL_ASSOCIATIONS, "+2", "+3"] as const;
if (plusTier === 3) return [...DEFAULT_VIRTUAL_ASSOCIATIONS, "+3"] as const;
logger.error("Invalid plusTier", { plusTier });
return [];
return [...DEFAULT_VIRTUAL_ASSOCIATIONS];
}
type InsertArgs = Omit<TablesInsertable["Association"], "inviteCode"> & {

View File

@@ -1,5 +1,5 @@
export const ASSOCIATION = {
VIRTUAL_IDENTIFIERS: ["+1", "+2", "+3"] as const,
VIRTUAL_IDENTIFIERS: ["+1", "+2", "+3", "FRIENDS"] as const,
MAX_COUNT_REGULAR_USER: 3,
MAX_COUNT_SUPPORTER: 6,
MAX_ASSOCIATION_MEMBER_COUNT: 300,

View File

@@ -103,6 +103,63 @@ describe("isVisible", () => {
expect(Association.isVisible(args)).toBe(true);
});
it("should return true if viewer is a friend of the content owner", () => {
const args: Association.IsVisibleArgs = {
visibility: { forAssociation: "FRIENDS" },
time: new Date(),
associations: {
actual: [],
virtual: [],
friendIds: [42],
},
contentOwnerUserId: 42,
};
expect(Association.isVisible(args)).toBe(true);
});
it("should return false if viewer is not a friend of the content owner", () => {
const args: Association.IsVisibleArgs = {
visibility: { forAssociation: "FRIENDS" },
time: new Date(),
associations: {
actual: [],
virtual: [],
friendIds: [99],
},
contentOwnerUserId: 42,
};
expect(Association.isVisible(args)).toBe(false);
});
it("should return false for FRIENDS visibility when not logged in", () => {
const args: Association.IsVisibleArgs = {
visibility: { forAssociation: "FRIENDS" },
time: new Date(),
associations: null,
};
expect(Association.isVisible(args)).toBe(false);
});
it("should return true when FRIENDS visibility becomes public via notFoundInstructions", () => {
const visibleAt = add(new Date(), { days: 1 });
const args: Association.IsVisibleArgs = {
visibility: {
forAssociation: "FRIENDS",
notFoundInstructions: [
{ at: dateToDatabaseTimestamp(visibleAt), forAssociation: null },
],
},
time: add(new Date(), { days: 2 }),
associations: {
actual: [],
virtual: [],
friendIds: [],
},
};
expect(Association.isVisible(args)).toBe(true);
});
it("should return true if has become public (no associations)", () => {
const visibleAt = add(new Date(), { days: 1 });

View File

@@ -8,7 +8,9 @@ export interface IsVisibleArgs {
associations: {
virtual: Array<string>;
actual: Array<{ id: number }>;
friendIds?: Array<number>;
} | null;
contentOwnerUserId?: number;
}
export function isVisible(args: IsVisibleArgs) {
@@ -29,6 +31,14 @@ export function isVisible(args: IsVisibleArgs) {
if (isPublic) return true;
if (
currentVisibility.includes("FRIENDS") &&
args.contentOwnerUserId &&
args.associations?.friendIds?.includes(args.contentOwnerUserId)
) {
return true;
}
return (
args.associations?.actual.some((association) =>
currentVisibility.includes(association.id),

View File

@@ -72,12 +72,12 @@ export default function EventsPage() {
)}
</div>
{hasNoEventsAtAll ? (
<p className="text-lighter text-sm">
<p className="no-results mt-4">
{t("calendar:events.emptyAll")}{" "}
<Link to={CALENDAR_PAGE}>{t("calendar:events.findOnCalendar")}</Link>
</p>
) : shownEvents.length === 0 ? (
<p className="text-lighter text-sm">{t("calendar:events.empty")}</p>
<p className="no-results mt-4">{t("calendar:events.empty")}</p>
) : (
<EventsList events={shownEvents} />
)}

View File

@@ -32,9 +32,49 @@ function flattenServerRoom(serverRoom: ServerRoomInfo): RoomInfo {
expiresAt: serverRoom.metadata.expiresAt,
lastMessageTimestamp: serverRoom.lastMessageTimestamp ?? 0,
totalMessageCount: serverRoom.totalMessageCount,
createdAt: serverRoom.metadata.createdAt ?? 0,
};
}
const SENDOUQ_GROUP_HEADER_PATTERN = /^Group\b/;
function isSendouQGroupRoom(room: RoomInfo) {
return (
room.subtitle === "SendouQ" &&
SENDOUQ_GROUP_HEADER_PATTERN.test(room.header)
);
}
function resolveObsoleteGroupRooms(rooms: RoomInfo[]): RoomInfo[] {
const groupRooms = rooms.filter(isSendouQGroupRoom);
if (groupRooms.length <= 1) return rooms;
const newestCreatedAt = Math.max(...groupRooms.map((r) => r.createdAt));
// If no room has createdAt set, skip resolution
if (newestCreatedAt === 0) return rooms;
const obsoleteChatCodes = new Set<string>();
const removedChatCodes = new Set<string>();
for (const room of groupRooms) {
if (room.createdAt === newestCreatedAt) continue;
if (room.totalMessageCount === 0) {
removedChatCodes.add(room.chatCode);
} else {
obsoleteChatCodes.add(room.chatCode);
}
}
return rooms
.filter((r) => !removedChatCodes.has(r.chatCode))
.map((r) =>
obsoleteChatCodes.has(r.chatCode) ? { ...r, isObsolete: true } : r,
);
}
function localStorageKey(chatCode: string) {
return `${LOCAL_STORAGE_PREFIX}${chatCode}`;
}
@@ -116,7 +156,9 @@ function ChatProviderInner({
if (parsed.rooms && Array.isArray(parsed.rooms)) {
logger.debug("WS initial rooms payload, count:", parsed.rooms.length);
const serverRooms = parsed.rooms as ServerRoomInfo[];
const roomList = serverRooms.map(flattenServerRoom);
const roomList = resolveObsoleteGroupRooms(
serverRooms.map(flattenServerRoom),
);
setRooms(roomList);
setIsLoading(false);
@@ -157,13 +199,20 @@ function ChatProviderInner({
setRooms((prev) => {
const exists = prev.some((r) => r.chatCode === newRoom.chatCode);
if (exists) return prev;
return [...prev, newRoom];
return resolveObsoleteGroupRooms([...prev, newRoom]);
});
setChatUsersCache((prev) => ({
...prev,
...serverRoom.metadata.chatUsers,
}));
const lastRead = readLastReadCount(serverRoom.chatCode);
const unread = Math.max(0, serverRoom.totalMessageCount - lastRead);
setUnreadCounts((prev) => ({
...prev,
[serverRoom.chatCode]: unread,
}));
return;
}
@@ -194,6 +243,7 @@ function ChatProviderInner({
expiresAt: metadata.expiresAt,
lastMessageTimestamp: messages.at(-1)?.timestamp ?? 0,
totalMessageCount: messages.length,
createdAt: metadata.createdAt ?? 0,
};
setRooms((prev) => {
const exists = prev.some((r) => r.chatCode === chatCode);
@@ -212,7 +262,7 @@ function ChatProviderInner({
// Regular message(s)
const messageArr = (
Array.isArray(parsed) ? parsed : [parsed]
) as (ChatMessage & { totalMessageCount?: number })[];
) as (ChatMessage & { totalMessageCount: number })[];
const isSystemMessage = Boolean(messageArr[0].type);
logger.debug(
@@ -253,30 +303,31 @@ function ChatProviderInner({
return { ...prev, [roomCode]: [...existing, msg] };
});
if (msg.totalMessageCount) {
setRooms((prev) =>
prev.map((r) =>
r.chatCode === roomCode
? {
...r,
lastMessageTimestamp: msg.timestamp,
totalMessageCount: Math.max(
r.totalMessageCount,
msg.totalMessageCount!,
),
}
: r,
),
);
setRooms((prev) =>
prev.map((r) =>
r.chatCode === roomCode
? {
...r,
lastMessageTimestamp: msg.timestamp,
totalMessageCount: Math.max(
r.totalMessageCount,
msg.totalMessageCount!,
),
}
: r,
),
);
if (roomCode === activeRoom && chatOpen) {
writeLastReadCount(roomCode, msg.totalMessageCount);
} else {
setUnreadCounts((prev) => ({
...prev,
[roomCode]: (prev[roomCode] ?? 0) + 1,
}));
}
if (roomCode === activeRoom && chatOpen) {
writeLastReadCount(roomCode, msg.totalMessageCount);
} else {
setUnreadCounts((prev) => ({
...prev,
[roomCode]: Math.max(
0,
msg.totalMessageCount - readLastReadCount(roomCode),
),
}));
}
}
});
@@ -323,7 +374,8 @@ function ChatProviderInner({
const handleStorage = (e: StorageEvent) => {
if (!e.key?.startsWith(LOCAL_STORAGE_PREFIX)) return;
const chatCode = e.key.slice(LOCAL_STORAGE_PREFIX.length);
const newCount = e.newValue ? Number(e.newValue) : 0;
const parsed = Number(e.newValue);
const newCount = Number.isFinite(parsed) ? parsed : 0;
setUnreadCounts((prev) => {
const room = rooms.find((r) => r.chatCode === chatCode);

View File

@@ -9,6 +9,7 @@ export interface RoomMetadata {
subtitle?: string;
url?: string;
imageUrl?: string;
createdAt?: number;
}
/** Room info as returned by Skalop on connect and ROOM_JOINED events */
@@ -30,6 +31,8 @@ export interface RoomInfo {
expiresAt: number;
lastMessageTimestamp: number;
totalMessageCount: number;
createdAt: number;
isObsolete?: boolean;
}
export interface ChatContextValue {

View File

@@ -1,87 +1,52 @@
import { type SelectQueryBuilder, sql } from "kysely";
import { db } from "~/db/sql";
import type { DB } from "~/db/tables";
import { dateToDatabaseTimestamp } from "~/utils/dates";
import { COMMON_USER_FIELDS } from "~/utils/kysely.server";
export async function findByUserIdWithActivity(userId: number) {
const [friendRows, teamMemberRows] = await Promise.all([
db
.selectFrom("Friendship")
.innerJoin("User", (join) =>
join.on((eb) =>
withLfgJoins(
db
.selectFrom("Friendship")
.innerJoin("User", (join) =>
join.on((eb) =>
eb.or([
eb.and([
eb("Friendship.userOneId", "=", userId),
eb("User.id", "=", eb.ref("Friendship.userTwoId")),
]),
eb.and([
eb("Friendship.userTwoId", "=", userId),
eb("User.id", "=", eb.ref("Friendship.userOneId")),
]),
]),
),
)
.where((eb) =>
eb.or([
eb.and([
eb("Friendship.userOneId", "=", userId),
eb("User.id", "=", eb.ref("Friendship.userTwoId")),
]),
eb.and([
eb("Friendship.userTwoId", "=", userId),
eb("User.id", "=", eb.ref("Friendship.userOneId")),
]),
eb("Friendship.userOneId", "=", userId),
eb("Friendship.userTwoId", "=", userId),
]),
),
)
.leftJoin("TournamentSub", "TournamentSub.userId", "User.id")
.leftJoin(
"CalendarEvent",
"CalendarEvent.tournamentId",
"TournamentSub.tournamentId",
)
.leftJoin(
"CalendarEventDate",
"CalendarEventDate.eventId",
"CalendarEvent.id",
)
)
.select([
"Friendship.id as friendshipId",
"User.id",
"User.username",
"User.discordId",
"User.discordAvatar",
"User.customUrl",
"CalendarEvent.name as tournamentName",
"TournamentSub.tournamentId",
"CalendarEventDate.startTime as tournamentStartTime",
"Friendship.createdAt as friendshipCreatedAt",
])
.where((eb) =>
eb.or([
eb("Friendship.userOneId", "=", userId),
eb("Friendship.userTwoId", "=", userId),
]),
)
.orderBy("Friendship.createdAt", "desc")
.execute(),
db
.selectFrom("TeamMemberWithSecondary as myMembership")
.innerJoin("TeamMemberWithSecondary as otherMembership", (join) =>
join
.onRef("otherMembership.teamId", "=", "myMembership.teamId")
.on("otherMembership.userId", "!=", userId),
)
.innerJoin("User", "User.id", "otherMembership.userId")
.leftJoin("TournamentSub", "TournamentSub.userId", "User.id")
.leftJoin(
"CalendarEvent",
"CalendarEvent.tournamentId",
"TournamentSub.tournamentId",
)
.leftJoin(
"CalendarEventDate",
"CalendarEventDate.eventId",
"CalendarEvent.id",
)
.select([
"User.id",
"User.username",
"User.discordId",
"User.discordAvatar",
"User.customUrl",
"CalendarEvent.name as tournamentName",
"TournamentSub.tournamentId",
"CalendarEventDate.startTime as tournamentStartTime",
])
.where("myMembership.userId", "=", userId)
.where("myMembership.leftAt", "is", null)
.where("otherMembership.leftAt", "is", null)
.execute(),
withLfgJoins(
db
.selectFrom("TeamMemberWithSecondary as myMembership")
.innerJoin("TeamMemberWithSecondary as otherMembership", (join) =>
join
.onRef("otherMembership.teamId", "=", "myMembership.teamId")
.on("otherMembership.userId", "!=", userId),
)
.innerJoin("User", "User.id", "otherMembership.userId")
.where("myMembership.userId", "=", userId),
).execute(),
]);
return [
@@ -94,6 +59,63 @@ export async function findByUserIdWithActivity(userId: number) {
];
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function withLfgJoins<QB extends SelectQueryBuilder<any, any, any>>(qb: QB) {
const nowTimestamp = dateToDatabaseTimestamp(new Date());
return (qb as SelectQueryBuilder<DB, keyof DB, Record<string, never>>)
.leftJoin("TournamentTeamMember", (join) =>
join
.onRef("TournamentTeamMember.userId", "=", "User.id")
.on("TournamentTeamMember.isLooking", "=", 1),
)
.leftJoin(
"TournamentTeam",
"TournamentTeam.id",
"TournamentTeamMember.tournamentTeamId",
)
.leftJoin("Tournament", (join) =>
join
.onRef("Tournament.id", "=", "TournamentTeam.tournamentId")
.on((eb) =>
eb.or([
eb(
sql`json_extract("Tournament"."settings", '$.regClosesAt')`,
"is",
null,
),
eb(
sql<number>`json_extract("Tournament"."settings", '$.regClosesAt')`,
">",
nowTimestamp,
),
]),
),
)
.leftJoin("CalendarEvent", "CalendarEvent.tournamentId", "Tournament.id")
.leftJoin(
"CalendarEventDate",
"CalendarEventDate.eventId",
"CalendarEvent.id",
)
.select([
...COMMON_USER_FIELDS,
"CalendarEvent.name as tournamentName",
"TournamentTeam.tournamentId",
"CalendarEventDate.startTime as tournamentStartTime",
sql<
number | null
>`(SELECT COUNT(*) FROM "TournamentTeamMember" "ttm" WHERE "ttm"."tournamentTeamId" = "TournamentTeam"."id")`.as(
"teamMemberCount",
),
sql<
number | null
>`json_extract("Tournament"."settings", '$.minMembersPerTeam')`.as(
"tournamentMinTeamSize",
),
]);
}
export async function findPendingSentRequests(senderId: number) {
return db
.selectFrom("FriendRequest")
@@ -333,6 +355,29 @@ export async function countPendingSentRequests(
return result.count;
}
export async function findFriendIds(userId: number): Promise<number[]> {
const rows = await db
.selectFrom("Friendship")
.select((eb) =>
eb
.case()
.when("Friendship.userOneId", "=", userId)
.then(eb.ref("Friendship.userTwoId"))
.else(eb.ref("Friendship.userOneId"))
.end()
.as("friendId"),
)
.where((eb) =>
eb.or([
eb("Friendship.userOneId", "=", userId),
eb("Friendship.userTwoId", "=", userId),
]),
)
.execute();
return rows.map((row) => row.friendId);
}
export async function findFriendship({
userOneId,
userTwoId,

View File

@@ -5,6 +5,8 @@ import { SENDOUQ_ACTIVITY_LABEL } from "./friends-constants";
export function resolveFriendActivity(
friendId: number,
tournamentName: string | null,
teamMemberCount: number | null,
tournamentMinTeamSize: number | null,
) {
const ownGroup = SendouQ.findOwnGroup(friendId);
@@ -18,7 +20,7 @@ export function resolveFriendActivity(
if (tournamentName) {
return {
subtitle: tournamentName,
badge: `1/${FULL_GROUP_SIZE}`,
badge: `${teamMemberCount ?? 1}/${tournamentMinTeamSize ?? FULL_GROUP_SIZE}`,
};
}

View File

@@ -21,7 +21,12 @@ export const loader = async () => {
const friends = unique
.filter((f) => f.friendshipId !== null)
.map((friend) => {
const activity = resolveFriendActivity(friend.id, friend.tournamentName);
const activity = resolveFriendActivity(
friend.id,
friend.tournamentName,
friend.teamMemberCount,
friend.tournamentMinTeamSize,
);
return {
id: friend.id,
@@ -51,7 +56,12 @@ export const loader = async () => {
const teamMembers = unique
.filter((f) => f.friendshipId === null)
.map((tm) => {
const activity = resolveFriendActivity(tm.id, tm.tournamentName);
const activity = resolveFriendActivity(
tm.id,
tm.tournamentName,
tm.teamMemberCount,
tm.tournamentMinTeamSize,
);
return {
id: tm.id,

View File

@@ -170,9 +170,9 @@ function FriendsListSection() {
: "friends:friendsList.empty";
return (
<section>
<section className="stack md">
<div className={styles.friendsListHeader}>
<h2 className="text-lg">{t("friends:friendsList.title")}</h2>
<h2 className="text-lg ml-1">{t("friends:friendsList.title")}</h2>
<SubNav secondary>
{VIEW_FILTERS.map((value) => (
<SubNavLink
@@ -188,15 +188,17 @@ function FriendsListSection() {
))}
</SubNav>
</div>
{shownItems.length === 0 ? (
<p className="text-lighter text-sm">{t(emptyKey)}</p>
) : (
<div className="stack xs">
{shownItems.map((item) => (
<FriendMenu key={item.id} name={item.username} {...item} />
))}
</div>
)}
<div>
{shownItems.length === 0 ? (
<p className="no-results">{t(emptyKey)}</p>
) : (
<div className="stack xs">
{shownItems.map((item) => (
<FriendMenu key={item.id} name={item.username} {...item} />
))}
</div>
)}
</div>
</section>
);
}

View File

@@ -34,6 +34,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => {
associations,
time: now,
visibility: post.visibility,
contentOwnerUserId: post.users.find((u) => u.isOwner)?.id,
}),
)
.map((post) => ({

View File

@@ -257,7 +257,9 @@ function AssociationSelect({
<option value="PUBLIC">{t("scrims:forms.visibility.public")}</option>
{associations.virtual.map((association) => (
<option key={association} value={association}>
{association}
{association === "FRIENDS"
? t("scrims:forms.visibility.friends")
: association}
</option>
))}
{associations.actual.map((association) => (

View File

@@ -34,8 +34,8 @@ import { AddPrivateNoteDialog } from "~/features/sendouq-match/components/AddPri
import type { ReportedWeaponForMerging } from "~/features/sendouq-match/core/reported-weapons.server";
import { resolveRoomPass } from "~/features/tournament-bracket/tournament-bracket-utils";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useMainContentWidth } from "~/hooks/useMainContentWidth";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { useWindowSize } from "~/hooks/useWindowSize";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids";
import { useHasRole } from "~/modules/permissions/hooks";
@@ -650,8 +650,8 @@ function BottomSection({
participatingInTheMatch: boolean;
}) {
const { t } = useTranslation(["q", "common"]);
const { width } = useWindowSize();
const isMobile = width < 750;
const width = useMainContentWidth();
const isMobile = width < 650;
const isMounted = useIsMounted();
const user = useUser();
const isStaff = useHasRole("STAFF");

View File

@@ -27,7 +27,7 @@
.mapButton {
background-image: var(--map-image-url);
background-size: contain;
background-size: cover;
height: var(--map-height);
width: var(--map-width);
border: none;

View File

@@ -25,7 +25,7 @@
gap: var(--s-2);
align-items: center;
background-color: var(--color-bg);
border-radius: var(--radius-avatar);
border-radius: calc(var(--radius-box) + 6px);
font-size: var(--font-sm);
font-weight: var(--weight-semi);
padding-inline-end: var(--s-3);

View File

@@ -16,6 +16,7 @@ import { useUser } from "~/features/auth/core/user";
import { MATCHES_COUNT_NEEDED_FOR_LEADERBOARD } from "~/features/leaderboards/leaderboards-constants";
import { ordinalToRoundedSp } from "~/features/mmr/mmr-utils";
import type { TieredSkill } from "~/features/mmr/tiered.server";
import { useMainContentWidth } from "~/hooks/useMainContentWidth";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { languagesUnified } from "~/modules/i18n/config";
import { SPLATTERCOLOR_SCREEN_ID } from "~/modules/in-game-lists/weapon-ids";
@@ -36,7 +37,11 @@ import type {
SQMatchGroupMember,
SQOwnGroup,
} from "../core/SendouQ.server";
import { FULL_GROUP_SIZE, SENDOUQ } from "../q-constants";
import {
FULL_GROUP_SIZE,
IS_Q_LOOKING_MOBILE_BREAKPOINT,
SENDOUQ,
} from "../q-constants";
import { resolveFutureMatchModes } from "../q-utils";
import styles from "./GroupCard.module.css";
@@ -248,10 +253,13 @@ function GroupCardContainer({
groupId: number;
children: React.ReactNode;
}) {
const width = useMainContentWidth();
const layout = width < IS_Q_LOOKING_MOBILE_BREAKPOINT ? "mobile" : "desktop";
// we don't want it to animate
if (isOwnGroup) return <>{children}</>;
return <Flipped flipId={groupId}>{children}</Flipped>;
return <Flipped flipId={`${layout}-${groupId}`}>{children}</Flipped>;
}
function GroupMember({

View File

@@ -17,3 +17,6 @@ export const SENDOUQ_BEST_OF = 7;
export const JOIN_CODE_SEARCH_PARAM_KEY = "join";
export const USER_SKILLS_CACHE_KEY = "user-skills";
/** Breakpoint (in px) for the main container after which we switch from 3-column layout to tabs */
export const IS_Q_LOOKING_MOBILE_BREAKPOINT = 700;

View File

@@ -19,8 +19,8 @@ import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import { useAutoRefresh } from "~/hooks/useAutoRefresh";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useMainContentWidth } from "~/hooks/useMainContentWidth";
import { useTimeFormat } from "~/hooks/useTimeFormat";
import { useWindowSize } from "~/hooks/useWindowSize";
import { metaTags } from "~/utils/remix";
import type { SendouRouteHandle } from "~/utils/remix.server";
import {
@@ -36,7 +36,10 @@ import { GroupLeaver } from "../components/GroupLeaver";
import { MemberAdder } from "../components/MemberAdder";
import { groupExpiryStatus } from "../core/groups";
import { loader } from "../loaders/q.looking.server";
import { FULL_GROUP_SIZE } from "../q-constants";
import {
FULL_GROUP_SIZE,
IS_Q_LOOKING_MOBILE_BREAKPOINT,
} from "../q-constants";
export { action, loader };
import styles from "./q.looking.module.css";
@@ -209,11 +212,11 @@ function Groups() {
const data = useLoaderData<typeof loader>();
const isMounted = useIsMounted();
const { width } = useWindowSize();
const width = useMainContentWidth();
if (!isMounted) return null;
const isMobile = width < 750;
const isMobile = width < IS_Q_LOOKING_MOBILE_BREAKPOINT;
const isFullGroup =
data.ownGroup && data.ownGroup.members.length === FULL_GROUP_SIZE;
@@ -410,9 +413,9 @@ function Groups() {
}
function ColumnHeader({ children }: { children: React.ReactNode }) {
const { width } = useWindowSize();
const width = useMainContentWidth();
const isMobile = width < 750;
const isMobile = width < IS_Q_LOOKING_MOBILE_BREAKPOINT;
if (isMobile) return null;

View File

@@ -248,7 +248,12 @@ function resolveFriends(friendsWithActivity: FriendWithActivity[]) {
const inactiveFriends: FriendWithActivity[] = [];
for (const friend of friendRows) {
const activity = resolveFriendActivity(friend.id, friend.tournamentName);
const activity = resolveFriendActivity(
friend.id,
friend.tournamentName,
friend.teamMemberCount,
friend.tournamentMinTeamSize,
);
if (!activity.subtitle) {
inactiveFriends.push(friend);
@@ -291,7 +296,12 @@ function resolveFriends(friendsWithActivity: FriendWithActivity[]) {
if (result.length >= MAX_FRIENDS_VISIBLE) break;
if (shownIds.has(tm.id)) continue;
const activity = resolveFriendActivity(tm.id, tm.tournamentName);
const activity = resolveFriendActivity(
tm.id,
tm.tournamentName,
tm.teamMemberCount,
tm.tournamentMinTeamSize,
);
if (!activity.subtitle) {
inactiveTeamMembers.push(tm);
continue;

View File

@@ -15,7 +15,7 @@
gap: var(--s-2);
align-items: center;
background-color: var(--color-bg);
border-radius: var(--radius-box);
border-radius: calc(var(--radius-box) + 6px);
font-size: var(--font-sm);
font-weight: var(--weight-semi);
padding-inline-end: var(--s-3);

View File

@@ -1,6 +1,7 @@
import clsx from "clsx";
import { Mic, Star, Volume2, VolumeX } from "lucide-react";
import * as React from "react";
import { Flipped } from "react-flip-toolkit";
import { useTranslation } from "react-i18next";
import { Link, useFetcher } from "react-router";
import { Avatar } from "~/components/Avatar";
@@ -11,7 +12,9 @@ import { Image, WeaponImage } from "~/components/Image";
import { SubmitButton } from "~/components/SubmitButton";
import type { Pronouns } from "~/db/tables";
import { useUser } from "~/features/auth/core/user";
import { IS_Q_LOOKING_MOBILE_BREAKPOINT } from "~/features/sendouq/q-constants";
import { SendouForm } from "~/form/SendouForm";
import { useMainContentWidth } from "~/hooks/useMainContentWidth";
import { languagesUnified } from "~/modules/i18n/config";
import type { MainWeaponId } from "~/modules/in-game-lists/types";
import { navIconUrl, userPage } from "~/utils/urls";
@@ -64,54 +67,74 @@ export function LFGGroupCard({
: undefined;
return (
<section className={styles.group}>
{group.teamName ? (
<Divider smallText className={styles.teamHeader}>
{group.teamAvatarUrl ? (
<Avatar size="xxs" url={group.teamAvatarUrl} />
) : null}
<div className={styles.teamName}>{group.teamName}</div>
</Divider>
) : null}
<div className="stack md">
{group.members.map((member) => (
<LFGGroupMemberRow
key={member.discordId}
member={member}
showActions={showActions}
isOwnGroup={isOwnGroup}
<LFGGroupCardContainer groupId={group.id} isOwnGroup={isOwnGroup}>
<section className={styles.group}>
{group.teamName ? (
<Divider smallText className={styles.teamHeader}>
{group.teamAvatarUrl ? (
<Avatar size="xxs" url={group.teamAvatarUrl} />
) : null}
<div className={styles.teamName}>{group.teamName}</div>
</Divider>
) : null}
<div className="stack md">
{group.members.map((member) => (
<LFGGroupMemberRow
key={member.discordId}
member={member}
showActions={showActions}
isOwnGroup={isOwnGroup}
/>
))}
</div>
{isOwnGroup ? (
<LFGTeamNote
note={group.note}
editable={group.usersRole === "OWNER"}
isStayAsSub={currentMember?.isStayAsSub ?? false}
/>
))}
</div>
{isOwnGroup ? (
<LFGTeamNote
note={group.note}
editable={group.usersRole === "OWNER"}
isStayAsSub={currentMember?.isStayAsSub ?? false}
/>
) : null}
{action &&
(ownGroup?.usersRole === "OWNER" || ownGroup?.usersRole === "MANAGER") ? (
<fetcher.Form className="stack items-center" method="post">
<input type="hidden" name="targetTeamId" value={group.id} />
<SubmitButton
size="small"
variant={action === "UNLIKE" ? "destructive" : "outlined"}
_action={action}
state={fetcher.state}
>
{action === "LIKE"
? t("q:looking.groups.actions.invite")
: action === "ACCEPT"
? t("common:actions.accept")
: t("q:looking.groups.actions.undo")}
</SubmitButton>
</fetcher.Form>
) : null}
</section>
) : null}
{action &&
(ownGroup?.usersRole === "OWNER" ||
ownGroup?.usersRole === "MANAGER") ? (
<fetcher.Form className="stack items-center" method="post">
<input type="hidden" name="targetTeamId" value={group.id} />
<SubmitButton
size="small"
variant={action === "UNLIKE" ? "destructive" : "outlined"}
_action={action}
state={fetcher.state}
>
{action === "LIKE"
? t("q:looking.groups.actions.invite")
: action === "ACCEPT"
? t("common:actions.accept")
: t("q:looking.groups.actions.undo")}
</SubmitButton>
</fetcher.Form>
) : null}
</section>
</LFGGroupCardContainer>
);
}
function LFGGroupCardContainer({
isOwnGroup,
groupId,
children,
}: {
isOwnGroup: boolean;
groupId: number;
children: React.ReactNode;
}) {
const width = useMainContentWidth();
const layout = width < IS_Q_LOOKING_MOBILE_BREAKPOINT ? "mobile" : "desktop";
if (isOwnGroup) return <>{children}</>;
return <Flipped flipId={`${layout}-${groupId}`}>{children}</Flipped>;
}
function LFGGroupMemberRow({
member,
showActions,
@@ -284,6 +307,7 @@ function LFGMemberRoleManager({
trigger={
<SendouButton
variant="minimal"
size="miniscule"
icon={
<Star
className={clsx(styles.star, {
@@ -367,7 +391,7 @@ function LFGVoiceChatInfo({
trigger={
<SendouButton
variant="minimal"
size="small"
size="miniscule"
icon={<Icon className={clsx(styles.vcIcon, color())} />}
/>
}

View File

@@ -19,10 +19,11 @@ import { WeaponImage } from "~/components/Image";
import { Placeholder } from "~/components/Placeholder";
import { SubmitButton } from "~/components/SubmitButton";
import { useUser } from "~/features/auth/core/user";
import { IS_Q_LOOKING_MOBILE_BREAKPOINT } from "~/features/sendouq/q-constants";
import { useTournament } from "~/features/tournament/routes/to.$id";
import { SendouForm } from "~/form/SendouForm";
import { useIsMounted } from "~/hooks/useIsMounted";
import { useWindowSize } from "~/hooks/useWindowSize";
import { useMainContentWidth } from "~/hooks/useMainContentWidth";
import type { SendouRouteHandle } from "~/utils/remix.server";
import { userPage } from "~/utils/urls";
import { LFGGroupCard } from "../components/LFGGroupCard";
@@ -69,9 +70,9 @@ function GroupsView({
}) {
const { t } = useTranslation(["q"]);
const tournament = useTournament();
const { width } = useWindowSize();
const width = useMainContentWidth();
const isMobile = width < 750;
const isMobile = width < IS_Q_LOOKING_MOBILE_BREAKPOINT;
const ownMemberCount =
data.ownGroup?.members.length ?? data.ownTeam?.members.length ?? 0;
@@ -373,9 +374,9 @@ function SubCard({ sub }: { sub: SubEntry }) {
}
function ColumnHeader({ children }: { children: React.ReactNode }) {
const { width } = useWindowSize();
const width = useMainContentWidth();
const isMobile = width < 750;
const isMobile = width < IS_Q_LOOKING_MOBILE_BREAKPOINT;
if (isMobile) return null;

View File

@@ -1,15 +0,0 @@
import { sql } from "~/db/sql";
import type { Tables } from "~/db/tables";
const stm = sql.prepare(/* sql */ `
delete from "TournamentSub"
where
"userId" = @userId and
"tournamentId" = @tournamentId
`);
export function deleteSub(
args: Pick<Tables["TournamentSub"], "userId" | "tournamentId">,
) {
stm.run(args);
}

View File

@@ -12,7 +12,7 @@ import {
clearTournamentDataCache,
tournamentFromDB,
} from "~/features/tournament-bracket/core/Tournament.server";
import { deleteSub } from "~/features/tournament-subs/queries/deleteSub.server";
import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import invariant from "~/utils/invariant";
import { logger } from "~/utils/logger";
@@ -83,7 +83,10 @@ export const action: ActionFunction = async ({ request, params }) => {
userId: data.userId,
tournamentId,
});
deleteSub({ tournamentId, userId: data.userId });
await TournamentLFGRepository.leaveLfg({
userId: data.userId,
tournamentId,
});
ShowcaseTournaments.addToCached({
tournamentId,
@@ -247,6 +250,10 @@ export const action: ActionFunction = async ({ request, params }) => {
"User has no friend code set",
);
await TournamentLFGRepository.leaveLfg({
userId: data.userId,
tournamentId,
});
joinTeam({
userId: data.userId,
newTeamId: team.id,

View File

@@ -12,7 +12,6 @@ import {
tournamentFromDB,
} from "~/features/tournament-bracket/core/Tournament.server";
import * as TournamentLFGRepository from "~/features/tournament-lfg/TournamentLFGRepository.server";
import { deleteSub } from "~/features/tournament-subs/queries/deleteSub.server";
import * as UserRepository from "~/features/user-page/UserRepository.server";
import { logger } from "~/utils/logger";
import {
@@ -139,7 +138,6 @@ export const action: ActionFunction = async ({ request, params }) => {
tournamentId,
avatarFileName,
});
deleteSub({ tournamentId, userId: user.id });
await SavedCalendarEventRepository.unsave({
userId: user.id,
tournamentId,

View File

@@ -1,5 +1,4 @@
import { sql } from "~/db/sql";
import { deleteSub } from "~/features/tournament-subs/queries/deleteSub.server";
import invariant from "~/utils/invariant";
import { checkOut } from "./checkOut.server";
@@ -26,6 +25,7 @@ const deleteMemberStm = sql.prepare(/*sql*/ `
and "userId" = @userId
`);
// xxx: migrate to kysely. note: deleteSub was deleted from here
export const joinTeam = sql.transaction(
({
previousTeamId,
@@ -33,7 +33,7 @@ export const joinTeam = sql.transaction(
newTeamId,
userId,
inGameName,
tournamentId,
tournamentId: _tournamentId,
checkOutTeam = false,
}: {
previousTeamId?: number;
@@ -50,10 +50,6 @@ export const joinTeam = sql.transaction(
deleteMemberStm.run({ tournamentTeamId: previousTeamId ?? null, userId });
}
if (!previousTeamId) {
deleteSub({ tournamentId, userId });
}
if (checkOutTeam) {
invariant(
previousTeamId,

View File

@@ -2,16 +2,16 @@ import { useState } from "react";
import { useEventListener } from "./useEventListener";
import { useIsomorphicLayoutEffect } from "./useIsomorphicLayoutEffect";
interface WindowSize {
width: number;
height: number;
}
const MOBILE_BREAKPOINT = 600;
const DESKTOP_BREAKPOINT = 1000;
type LayoutSize = "mobile" | "tablet" | "desktop";
interface WindowSize {
width: number;
height: number;
}
export function useLayoutSize(): LayoutSize {
const { width } = useWindowSize();
@@ -21,7 +21,28 @@ export function useLayoutSize(): LayoutSize {
return "desktop";
}
export function useWindowSize(): WindowSize {
export function useMainContentWidth() {
const [width, setWidth] = useState(0);
useIsomorphicLayoutEffect(() => {
const main = document.querySelector("main");
if (!main) return;
setWidth(main.clientWidth);
const observer = new ResizeObserver((entries) => {
setWidth(entries[0]?.contentRect.width);
});
observer.observe(main);
return () => observer.disconnect();
}, []);
return width;
}
function useWindowSize(): WindowSize {
const [windowSize, setWindowSize] = useState<WindowSize>({
width: 0,
height: 0,

View File

@@ -593,6 +593,8 @@
.top-leaderboard {
min-height: 100px;
margin-top: 10px;
display: grid;
place-items: center;
}
@media screen and (max-width: 599px) {

View File

@@ -83,6 +83,10 @@
text-decoration: underline;
}
.line-through {
text-decoration: line-through;
}
.color-success {
color: var(--color-success);
}
@@ -620,4 +624,11 @@
.clickable {
cursor: pointer;
}
.no-results {
text-align: center;
color: var(--color-text-high);
font-style: italic;
font-size: var(--font-xs);
}
}

View File

@@ -58,6 +58,7 @@
"forms.text.title": "",
"forms.visibility.title": "",
"forms.visibility.public": "",
"forms.visibility.friends": "",
"forms.visibility.noneAvailable": "",
"forms.notFoundVisibility.title": "",
"forms.notFoundVisibility.explanation": "",

View File

@@ -58,6 +58,7 @@
"forms.text.title": "Text",
"forms.visibility.title": "Sichtbarkeit",
"forms.visibility.public": "Öffentlich",
"forms.visibility.friends": "",
"forms.visibility.noneAvailable": "Keine Assoziationen verfügkar. Erstelle eine, um in einer kleineren Gruppe nach einem Scrim zu suchen.",
"forms.notFoundVisibility.title": "Wenn kein Scrim gefunden wurde bis",
"forms.notFoundVisibility.explanation": "Leer lassen, wenn sich die Sichtbarkeit deines Posts nicht mit der Zeit ändern soll.",

View File

@@ -58,6 +58,7 @@
"forms.text.title": "Text",
"forms.visibility.title": "Visibility",
"forms.visibility.public": "Public",
"forms.visibility.friends": "Friends only",
"forms.visibility.noneAvailable": "No associations available. Create one to look for a scrim in a smaller group.",
"forms.notFoundVisibility.title": "If not found by",
"forms.notFoundVisibility.explanation": "Leave blank if you don't want the visibility of your post to change over time",

View File

@@ -58,6 +58,7 @@
"forms.text.title": "Texto",
"forms.visibility.title": "Visibilidad",
"forms.visibility.public": "Pública",
"forms.visibility.friends": "",
"forms.visibility.noneAvailable": "No hay asociaciones disponibles. Crea una para buscar un scrim en un grupo más reducido.",
"forms.notFoundVisibility.title": "Si no se encuentra antes de",
"forms.notFoundVisibility.explanation": "Déjalo en blanco si no quieres que la visibilidad de tu publicación cambie con el tiempo",

View File

@@ -58,6 +58,7 @@
"forms.text.title": "",
"forms.visibility.title": "",
"forms.visibility.public": "",
"forms.visibility.friends": "",
"forms.visibility.noneAvailable": "",
"forms.notFoundVisibility.title": "",
"forms.notFoundVisibility.explanation": "",

View File

@@ -58,6 +58,7 @@
"forms.text.title": "",
"forms.visibility.title": "",
"forms.visibility.public": "",
"forms.visibility.friends": "",
"forms.visibility.noneAvailable": "",
"forms.notFoundVisibility.title": "",
"forms.notFoundVisibility.explanation": "",

View File

@@ -58,6 +58,7 @@
"forms.text.title": "Texte",
"forms.visibility.title": "Visibilité",
"forms.visibility.public": "Public",
"forms.visibility.friends": "",
"forms.visibility.noneAvailable": "Aucun association disponible. Créez-en un pour rechercher un scrim dans un groupe plus petit.",
"forms.notFoundVisibility.title": "S'il n'est pas trouvé par",
"forms.notFoundVisibility.explanation": "Laissez vide si vous ne souhaitez pas que la visibilité de votre post change au fil du temps",

View File

@@ -58,6 +58,7 @@
"forms.text.title": "",
"forms.visibility.title": "",
"forms.visibility.public": "",
"forms.visibility.friends": "",
"forms.visibility.noneAvailable": "",
"forms.notFoundVisibility.title": "",
"forms.notFoundVisibility.explanation": "",

View File

@@ -58,6 +58,7 @@
"forms.text.title": "",
"forms.visibility.title": "",
"forms.visibility.public": "",
"forms.visibility.friends": "",
"forms.visibility.noneAvailable": "",
"forms.notFoundVisibility.title": "",
"forms.notFoundVisibility.explanation": "",

View File

@@ -58,6 +58,7 @@
"forms.text.title": "",
"forms.visibility.title": "",
"forms.visibility.public": "",
"forms.visibility.friends": "",
"forms.visibility.noneAvailable": "",
"forms.notFoundVisibility.title": "",
"forms.notFoundVisibility.explanation": "",

View File

@@ -58,6 +58,7 @@
"forms.text.title": "",
"forms.visibility.title": "",
"forms.visibility.public": "",
"forms.visibility.friends": "",
"forms.visibility.noneAvailable": "",
"forms.notFoundVisibility.title": "",
"forms.notFoundVisibility.explanation": "",

View File

@@ -58,6 +58,7 @@
"forms.text.title": "",
"forms.visibility.title": "",
"forms.visibility.public": "",
"forms.visibility.friends": "",
"forms.visibility.noneAvailable": "",
"forms.notFoundVisibility.title": "",
"forms.notFoundVisibility.explanation": "",

View File

@@ -58,6 +58,7 @@
"forms.text.title": "",
"forms.visibility.title": "",
"forms.visibility.public": "",
"forms.visibility.friends": "",
"forms.visibility.noneAvailable": "",
"forms.notFoundVisibility.title": "",
"forms.notFoundVisibility.explanation": "",

View File

@@ -58,6 +58,7 @@
"forms.text.title": "",
"forms.visibility.title": "",
"forms.visibility.public": "",
"forms.visibility.friends": "",
"forms.visibility.noneAvailable": "",
"forms.notFoundVisibility.title": "",
"forms.notFoundVisibility.explanation": "",

View File

@@ -58,6 +58,7 @@
"forms.text.title": "",
"forms.visibility.title": "",
"forms.visibility.public": "",
"forms.visibility.friends": "",
"forms.visibility.noneAvailable": "",
"forms.notFoundVisibility.title": "",
"forms.notFoundVisibility.explanation": "",

View File

@@ -58,6 +58,7 @@
"forms.text.title": "",
"forms.visibility.title": "",
"forms.visibility.public": "",
"forms.visibility.friends": "",
"forms.visibility.noneAvailable": "",
"forms.notFoundVisibility.title": "",
"forms.notFoundVisibility.explanation": "",