mirror of
https://github.com/Vendicated/Vencord.git
synced 2024-09-20 06:30:35 +00:00
Compare commits
35 commits
332a3cddb2
...
895882cd6f
Author | SHA1 | Date | |
---|---|---|---|
|
895882cd6f | ||
|
f12335a371 | ||
|
640d99dcda | ||
|
0187e5bd96 | ||
|
4d1cf661f5 | ||
|
e41fed6372 | ||
|
b0daabdf71 | ||
|
fc3f7761fd | ||
|
1502ae688f | ||
|
16d349e6ed | ||
|
f26111ad27 | ||
|
69efbaa06d | ||
|
4fae539fa4 | ||
|
f866808e23 | ||
|
7bb0bc4700 | ||
|
d0db4aa634 | ||
|
84aaac67b7 | ||
|
381dd6e438 | ||
|
fec2638fd5 | ||
|
71e3a5cc99 | ||
|
6b88d4e4bd | ||
|
c2c5771b21 | ||
|
f0077caa3c | ||
|
e4fa9320d6 | ||
|
8c26c493f0 | ||
|
5f539208bf | ||
|
ce7e0e6c72 | ||
|
b4cc9844d5 | ||
|
f31805c02f | ||
|
96894c21a7 | ||
|
a4408263a7 | ||
|
a5be1873ba | ||
|
a43d5d595a | ||
|
ceaaf9ab8a | ||
|
a01ee40591 |
19 changed files with 1317 additions and 177 deletions
|
@ -1,5 +0,0 @@
|
|||
# NoDefaultHangStatus
|
||||
|
||||
Disable the default hang status when joining voice channels
|
||||
|
||||
![Visualization](https://github.com/Vendicated/Vencord/assets/24937357/329a9742-236f-48f7-94ff-c3510eca505a)
|
|
@ -1,24 +0,0 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin from "@utils/types";
|
||||
|
||||
export default definePlugin({
|
||||
name: "NoDefaultHangStatus",
|
||||
description: "Disable the default hang status when joining voice channels",
|
||||
authors: [Devs.D3SOX],
|
||||
|
||||
patches: [
|
||||
{
|
||||
find: ".CHILLING)",
|
||||
replacement: {
|
||||
match: /{enableHangStatus:(\i),/,
|
||||
replace: "{_enableHangStatus:$1=false,"
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
7
src/plugins/notesSearcher/README.md
Normal file
7
src/plugins/notesSearcher/README.md
Normal file
|
@ -0,0 +1,7 @@
|
|||
# NotesSearcher
|
||||
|
||||
Allows you to open a modal with all of your notes and search through them by user ID, note text, and username
|
||||
|
||||
## Preview
|
||||
|
||||
![preview](https://i.imgur.com/yBwhcx6.png)
|
76
src/plugins/notesSearcher/components/Icons.tsx
Normal file
76
src/plugins/notesSearcher/components/Icons.tsx
Normal file
|
@ -0,0 +1,76 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { LazyComponent } from "@utils/lazyReact";
|
||||
import { React } from "@webpack/common";
|
||||
|
||||
export const NotesDataIcon = LazyComponent(() => React.memo(() => {
|
||||
return (
|
||||
<svg stroke="currentColor" width="24" height="24" viewBox="1 1 22 22" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" d="M10.0002 4H7.2002C6.08009 4 5.51962 4 5.0918 4.21799C4.71547 4.40973 4.40973 4.71547 4.21799 5.0918C4 5.51962 4 6.08009 4 7.2002V16.8002C4 17.9203 4 18.4801 4.21799 18.9079C4.40973 19.2842 4.71547 19.5905 5.0918 19.7822C5.5192 20 6.07899 20 7.19691 20H16.8031C17.921 20 18.48 20 18.9074 19.7822C19.2837 19.5905 19.5905 19.2839 19.7822 18.9076C20 18.4802 20 17.921 20 16.8031V14M16 5L10 11V14H13L19 8M16 5L19 2L22 5L19 8M16 5L19 8" />
|
||||
</svg>
|
||||
);
|
||||
}));
|
||||
|
||||
export const SaveIcon = LazyComponent(() => React.memo(() => {
|
||||
return (
|
||||
<svg width="32" height="32" viewBox="-4 -4 32 32">
|
||||
<path fill="#fff" fill-rule="evenodd" clip-rule="evenodd" d="M18.1716 1C18.702 1 19.2107 1.21071 19.5858 1.58579L22.4142 4.41421C22.7893 4.78929 23 5.29799 23 5.82843V20C23 21.6569 21.6569 23 20 23H4C2.34315 23 1 21.6569 1 20V4C1 2.34315 2.34315 1 4 1H18.1716ZM4 3C3.44772 3 3 3.44772 3 4V20C3 20.5523 3.44772 21 4 21L5 21L5 15C5 13.3431 6.34315 12 8 12L16 12C17.6569 12 19 13.3431 19 15V21H20C20.5523 21 21 20.5523 21 20V6.82843C21 6.29799 20.7893 5.78929 20.4142 5.41421L18.5858 3.58579C18.2107 3.21071 17.702 3 17.1716 3H17V5C17 6.65685 15.6569 8 14 8H10C8.34315 8 7 6.65685 7 5V3H4ZM17 21V15C17 14.4477 16.5523 14 16 14L8 14C7.44772 14 7 14.4477 7 15L7 21L17 21ZM9 3H15V5C15 5.55228 14.5523 6 14 6H10C9.44772 6 9 5.55228 9 5V3Z" />
|
||||
</svg>
|
||||
);
|
||||
}));
|
||||
|
||||
export const DeleteIcon = LazyComponent(() => React.memo(() => {
|
||||
return (
|
||||
<svg width="32" height="32" viewBox="-4 -4 32 32">
|
||||
<path fill="#fff" d="M16 9v10H8V9h8m-1.5-6h-5l-1 1H5v2h14V4h-3.5l-1-1zM18 7H6v12c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7z" />
|
||||
</svg>
|
||||
);
|
||||
}));
|
||||
|
||||
export const RefreshIcon = LazyComponent(() => React.memo(() => {
|
||||
return (
|
||||
<svg width="32" height="32" viewBox="-4 -4 32 32" fill="none">
|
||||
<path stroke-linejoin="round" stroke-linecap="round" stroke="#fff" stroke-width="2" d="M21 3V8M21 8H16M21 8L18 5.29168C16.4077 3.86656 14.3051 3 12 3C7.02944 3 3 7.02944 3 12C3 16.9706 7.02944 21 12 21C16.2832 21 19.8675 18.008 20.777 14" />
|
||||
</svg>
|
||||
);
|
||||
}));
|
||||
|
||||
export const PopupIcon = LazyComponent(() => React.memo(() => {
|
||||
return (
|
||||
<svg width="32" height="32" viewBox="-4 -4 32 32">
|
||||
<path fill="#fff" d="M10 5V3H5.375C4.06519 3 3 4.06519 3 5.375V18.625C3 19.936 4.06519 21 5.375 21H18.625C19.936 21 21 19.936 21 18.625V14H19V19H5V5H10Z" />
|
||||
<path fill="#fff" d="M21 2.99902H14V4.99902H17.586L9.29297 13.292L10.707 14.706L19 6.41302V9.99902H21V2.99902Z" />
|
||||
</svg>
|
||||
);
|
||||
}));
|
||||
|
||||
export const CrossIcon = LazyComponent(() => React.memo(() => {
|
||||
return (
|
||||
<svg width="32px" height="32px" viewBox="-3.2 -3.2 38.40 38.40">
|
||||
<rect fill="#dc3545" x="-3.2" y="-3.2" width="38.40" height="38.40" rx="19.2" />
|
||||
<path fill="#fff" d="M18.8,16l5.5-5.5c0.8-0.8,0.8-2,0-2.8l0,0C24,7.3,23.5,7,23,7c-0.5,0-1,0.2-1.4,0.6L16,13.2l-5.5-5.5 c-0.8-0.8-2.1-0.8-2.8,0C7.3,8,7,8.5,7,9.1s0.2,1,0.6,1.4l5.5,5.5l-5.5,5.5C7.3,21.9,7,22.4,7,23c0,0.5,0.2,1,0.6,1.4 C8,24.8,8.5,25,9,25c0.5,0,1-0.2,1.4-0.6l5.5-5.5l5.5,5.5c0.8,0.8,2.1,0.8,2.8,0c0.8-0.8,0.8-2.1,0-2.8L18.8,16z" />
|
||||
</svg>
|
||||
);
|
||||
}));
|
||||
|
||||
export const ProblemIcon = LazyComponent(() => React.memo(() => {
|
||||
return (
|
||||
<svg width="32px" height="32px" viewBox="0 0 1024 1024">
|
||||
<rect fill="#fbbd04" x="0" y="0" width="1024" height="1024" rx="512" />
|
||||
<path fill="#fff" d="M512 254.08a140.16 140.16 0 0 0-140.672 139.392 32.128 32.128 0 0 0 64.32 0c0-42.112 33.536-75.136 76.352-75.136 42.112 0 76.352 34.56 76.352 76.992 0 16-22.912 38.976-43.2 59.2-30.592 30.592-65.28 65.28-65.28 111.744v45.888a32.128 32.128 0 1 0 64.256 0v-45.888c0-19.84 23.68-43.52 46.464-66.304 29.056-29.056 62.08-62.016 62.08-104.64A141.12 141.12 0 0 0 512 254.08z m-48.192 500.928a48.192 48.192 0 1 0 96.384 0 48.192 48.192 0 0 0-96.384 0z" />
|
||||
</svg>
|
||||
);
|
||||
}));
|
||||
|
||||
export const SuccessIcon = LazyComponent(() => React.memo(() => {
|
||||
return (
|
||||
<svg width="32px" height="32px" viewBox="0.55 2.3 15.834375 15.834375">
|
||||
<circle fill="#fff" cx="8.5" cy="10.25" r="6.5" />
|
||||
<path fill="#28a745" d="M16.417 10.283A7.917 7.917 0 1 1 8.5 2.366a7.916 7.916 0 0 1 7.917 7.917zm-4.105-4.498a.791.791 0 0 0-1.082.29l-3.828 6.63-1.733-2.08a.791.791 0 1 0-1.216 1.014l2.459 2.952a.792.792 0 0 0 .608.285.83.83 0 0 0 .068-.003.791.791 0 0 0 .618-.393L12.6 6.866a.791.791 0 0 0-.29-1.081z" />
|
||||
</svg>
|
||||
);
|
||||
}));
|
29
src/plugins/notesSearcher/components/LoadingSpinner.tsx
Normal file
29
src/plugins/notesSearcher/components/LoadingSpinner.tsx
Normal file
|
@ -0,0 +1,29 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { LazyComponent } from "@utils/react";
|
||||
import { React } from "@webpack/common";
|
||||
|
||||
export const LoadingSpinner = LazyComponent(() => React.memo(() => {
|
||||
return (
|
||||
<div className={"vc-notes-searcher-modal-spinner-container"} style={{
|
||||
width: "56px",
|
||||
height: "56px",
|
||||
margin: "12px",
|
||||
}}>
|
||||
<span className={"vc-notes-searcher-modal-spinner"} style={{
|
||||
width: "56px",
|
||||
height: "56px",
|
||||
border: "5px solid #fff",
|
||||
borderRadius: "50%",
|
||||
display: "inline-block",
|
||||
boxSizing: "border-box",
|
||||
position: "relative",
|
||||
animation: "vc-notes-searcher-pulse 1s linear infinite",
|
||||
}} />
|
||||
</div>
|
||||
);
|
||||
}));
|
25
src/plugins/notesSearcher/components/NotesDataButton.tsx
Normal file
25
src/plugins/notesSearcher/components/NotesDataButton.tsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { LazyComponent } from "@utils/react";
|
||||
import { findExportedComponentLazy } from "@webpack";
|
||||
import { React } from "@webpack/common";
|
||||
|
||||
import { NotesDataIcon } from "./Icons";
|
||||
import { openNotesDataModal } from "./NotesDataModal";
|
||||
|
||||
const HeaderBarIcon = findExportedComponentLazy("Icon", "Divider");
|
||||
|
||||
export const OpenNotesDataButton = LazyComponent(() => React.memo(() => {
|
||||
return (
|
||||
<HeaderBarIcon
|
||||
className="vc-notes-searcher-toolbox-button"
|
||||
onClick={() => openNotesDataModal()}
|
||||
tooltip={"View Notes"}
|
||||
icon={NotesDataIcon}
|
||||
/>
|
||||
);
|
||||
}));
|
217
src/plugins/notesSearcher/components/NotesDataModal.tsx
Normal file
217
src/plugins/notesSearcher/components/NotesDataModal.tsx
Normal file
|
@ -0,0 +1,217 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { classNameFactory } from "@api/Styles";
|
||||
import {
|
||||
closeModal, ModalCloseButton, ModalContent, ModalHeader, ModalProps, ModalRoot, openModal
|
||||
} from "@utils/modal";
|
||||
import { LazyComponent } from "@utils/react";
|
||||
import { Button, React, RelationshipStore, Select, Text, TextInput, useCallback, useMemo, useReducer, useState } from "@webpack/common";
|
||||
|
||||
import { cacheUsers, getNotes, usersCache as usersCache$1 } from "../data";
|
||||
import NotesDataRow from "./NotesDataRow";
|
||||
|
||||
const cl = classNameFactory("vc-notes-searcher-modal-");
|
||||
|
||||
const enum SearchStatus {
|
||||
ALL,
|
||||
FRIENDS,
|
||||
BLOCKED,
|
||||
}
|
||||
|
||||
const filterUser = (query: string, userId: string, userNotes: string) => {
|
||||
if (query === "" || userId.includes(query)) return true;
|
||||
|
||||
query = query.toLowerCase();
|
||||
|
||||
const user = usersCache$1.get(userId);
|
||||
|
||||
return user && (
|
||||
user.globalName?.toLowerCase().includes(query) || user.username.toLowerCase().includes(query)
|
||||
) || userNotes.toLowerCase().includes(query);
|
||||
};
|
||||
|
||||
// looks like a shit but I don't know better way to do it
|
||||
// P.S. using `getNotes()` as deps for useMemo won't work due to object init outside of component
|
||||
let RefreshNotesDataEx: () => void | undefined;
|
||||
|
||||
export const refreshNotesData = () => {
|
||||
if (!RefreshNotesDataEx) return;
|
||||
|
||||
RefreshNotesDataEx();
|
||||
};
|
||||
|
||||
export function NotesDataModal({ modalProps, close }: {
|
||||
modalProps: ModalProps;
|
||||
close(): void;
|
||||
}) {
|
||||
const [searchValue, setSearchValue] = useState({ query: "", status: SearchStatus.ALL });
|
||||
|
||||
const onSearch = (query: string) => setSearchValue(prev => ({ ...prev, query }));
|
||||
const onStatusChange = (status: SearchStatus) => setSearchValue(prev => ({ ...prev, status }));
|
||||
|
||||
const [usersNotesData, refreshNotesData] = useReducer(() => {
|
||||
return Object.entries(getNotes())
|
||||
.map<[string, string]>(([userId, { note }]) => [userId, note])
|
||||
.filter((([_, note]) => note && note !== ""));
|
||||
},
|
||||
Object.entries(getNotes())
|
||||
.map<[string, string]>(([userId, { note }]) => [userId, note])
|
||||
.filter((([_, note]) => note && note !== ""))
|
||||
);
|
||||
|
||||
RefreshNotesDataEx = refreshNotesData;
|
||||
|
||||
const filteredNotes = useMemo(() => {
|
||||
const { query, status } = searchValue;
|
||||
|
||||
if (query === "" && status === SearchStatus.ALL) {
|
||||
return usersNotesData;
|
||||
}
|
||||
|
||||
return usersNotesData
|
||||
.filter(([userId, userNotes]) => {
|
||||
switch (status) {
|
||||
case SearchStatus.FRIENDS:
|
||||
return RelationshipStore.isFriend(userId) && filterUser(query, userId, userNotes);
|
||||
case SearchStatus.BLOCKED:
|
||||
return RelationshipStore.isBlocked(userId) && filterUser(query, userId, userNotes);
|
||||
default:
|
||||
return filterUser(query, userId, userNotes);
|
||||
}
|
||||
});
|
||||
}, [usersNotesData, searchValue]);
|
||||
|
||||
const [visibleNotesNum, setVisibleNotesNum] = useState(10);
|
||||
|
||||
const loadMore = useCallback(() => {
|
||||
setVisibleNotesNum(prevNum => prevNum + 10);
|
||||
}, []);
|
||||
|
||||
const visibleNotes = filteredNotes.slice(0, visibleNotesNum);
|
||||
|
||||
const canLoadMore = visibleNotesNum < filteredNotes.length;
|
||||
|
||||
return (
|
||||
<ModalRoot className={cl("root")} {...modalProps}>
|
||||
<ModalHeader className={cl("header")}>
|
||||
<Text className={cl("header-text")} variant="heading-lg/semibold" style={{ whiteSpace: "nowrap", width: "fit-content", marginRight: "16px" }}>User Notes</Text>
|
||||
<TextInput className={cl("header-input")} value={searchValue.query} onChange={onSearch} placeholder="Filter Notes (ID/Display Name/Username/Note Text)" style={{ width: "100% !important", marginRight: "16px" }} />
|
||||
<div className={cl("header-user-type")} style={{ minWidth: "160px", marginRight: "16px" }}>
|
||||
<Select
|
||||
options={[
|
||||
{ label: "Show All", value: SearchStatus.ALL, default: true },
|
||||
{ label: "Show Friends", value: SearchStatus.FRIENDS },
|
||||
{ label: "Show Blocked", value: SearchStatus.BLOCKED },
|
||||
]}
|
||||
serialize={String}
|
||||
select={onStatusChange}
|
||||
isSelected={v => v === searchValue.status}
|
||||
closeOnSelect={true}
|
||||
/>
|
||||
</div>
|
||||
<ModalCloseButton onClick={close} />
|
||||
</ModalHeader>
|
||||
<div style={{ opacity: modalProps.transitionState === 1 ? "1" : "0", overflow: "hidden", height: "100%" }} className={cl("content-container")}>
|
||||
{
|
||||
modalProps.transitionState === 1 &&
|
||||
<ModalContent className={cl("content")}>
|
||||
{
|
||||
!visibleNotes.length ? <NoNotes /> : (
|
||||
<NotesDataContent
|
||||
visibleNotes={visibleNotes}
|
||||
canLoadMore={canLoadMore}
|
||||
loadMore={loadMore}
|
||||
refreshNotesData={refreshNotesData}
|
||||
/>
|
||||
)
|
||||
}
|
||||
</ModalContent>
|
||||
}
|
||||
</div>
|
||||
</ModalRoot>
|
||||
);
|
||||
}
|
||||
|
||||
// looks like a shit but I don't know better way to do it
|
||||
// P.S. using `usersCache` as deps for useMemo won't work due to object init outside of component
|
||||
let RefreshUsersCacheEx: () => void | undefined;
|
||||
|
||||
export const refreshUsersCache = () => {
|
||||
if (!RefreshUsersCacheEx) return;
|
||||
|
||||
RefreshUsersCacheEx();
|
||||
};
|
||||
|
||||
const NotesDataContent = ({ visibleNotes, canLoadMore, loadMore, refreshNotesData }: {
|
||||
visibleNotes: [string, string][];
|
||||
canLoadMore: boolean;
|
||||
loadMore(): void;
|
||||
refreshNotesData(): void;
|
||||
}) => {
|
||||
if (!visibleNotes.length)
|
||||
return <NoNotes />;
|
||||
|
||||
const [usersCache, refreshUsersCache] = useReducer(() => {
|
||||
return new Map(usersCache$1);
|
||||
}, usersCache$1);
|
||||
|
||||
RefreshUsersCacheEx = refreshUsersCache;
|
||||
|
||||
return (
|
||||
<div className={cl("content-inner")} style={{ paddingTop: "16px", height: "fit-content" }}>
|
||||
{
|
||||
visibleNotes
|
||||
.map(([userId, userNotes]) => {
|
||||
return (
|
||||
<NotesDataRow
|
||||
key={userId}
|
||||
userId={userId}
|
||||
userNotes={userNotes}
|
||||
usersCache={usersCache}
|
||||
refreshNotesData={refreshNotesData}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
{
|
||||
canLoadMore &&
|
||||
<Button
|
||||
className={cl("load-more")}
|
||||
size={Button.Sizes.NONE}
|
||||
style={{ marginTop: "16px", width: "100%", height: "32px" }}
|
||||
onClick={() => loadMore()}
|
||||
>
|
||||
Load More
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const NoNotes = LazyComponent(() => React.memo(() => (
|
||||
<div className={cl("no-notes")} style={{ textAlign: "center", display: "grid", placeContent: "center", height: "100%" }}>
|
||||
<Text variant="text-lg/normal">
|
||||
No Notes.
|
||||
</Text>
|
||||
</div>
|
||||
)));
|
||||
|
||||
let fistTimeOpen = true;
|
||||
|
||||
export const openNotesDataModal = async () => {
|
||||
if (fistTimeOpen) {
|
||||
cacheUsers();
|
||||
fistTimeOpen = false;
|
||||
}
|
||||
|
||||
const key = openModal(modalProps => (
|
||||
<NotesDataModal
|
||||
modalProps={modalProps}
|
||||
close={() => closeModal(key)}
|
||||
/>
|
||||
));
|
||||
};
|
248
src/plugins/notesSearcher/components/NotesDataRow.tsx
Normal file
248
src/plugins/notesSearcher/components/NotesDataRow.tsx
Normal file
|
@ -0,0 +1,248 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { classNameFactory } from "@api/Styles";
|
||||
import { openPrivateChannel, openUserProfile } from "@utils/discord";
|
||||
import { copyWithToast } from "@utils/misc";
|
||||
import { Alerts, Avatar, Button, ContextMenuApi, Menu, React, Text, TextArea, Tooltip, useState } from "@webpack/common";
|
||||
|
||||
import { updateNote } from "../data";
|
||||
import { UsersCache } from "../types";
|
||||
import { DeleteIcon, PopupIcon, RefreshIcon, SaveIcon } from "./Icons";
|
||||
import { LoadingSpinner } from "./LoadingSpinner";
|
||||
|
||||
const cl = classNameFactory("vc-notes-searcher-modal-");
|
||||
|
||||
export default ({ userId, userNotes: userNotesArg, refreshNotesData, usersCache }: {
|
||||
userId: string;
|
||||
userNotes: string;
|
||||
refreshNotesData(): void;
|
||||
usersCache: UsersCache;
|
||||
}) => {
|
||||
let userCache = usersCache.get(userId);
|
||||
|
||||
const pending = !userCache;
|
||||
|
||||
userCache ??= {
|
||||
id: userId,
|
||||
globalName: "Loading...",
|
||||
username: "Loading...",
|
||||
avatar: "https://cdn.discordapp.com/embed/avatars/4.png",
|
||||
};
|
||||
|
||||
const [userNotes, setUserNotes] = useState(userNotesArg);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cl("user")}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "80px",
|
||||
display: "flex",
|
||||
flexDirection: "row",
|
||||
justifyContent: "flex-start",
|
||||
alignItems: "center",
|
||||
backgroundColor: "var(--background-secondary)",
|
||||
borderRadius: "12px",
|
||||
boxSizing: "border-box",
|
||||
}}
|
||||
onContextMenu={event => {
|
||||
ContextMenuApi.openContextMenu(event, () =>
|
||||
<Menu.Menu
|
||||
navId={cl("user-context-menu")}
|
||||
onClose={ContextMenuApi.closeContextMenu}
|
||||
aria-label="User Notes Data"
|
||||
>
|
||||
<Menu.MenuItem
|
||||
id={cl("open-user-profile")}
|
||||
label="Open User Profile"
|
||||
action={() => openUserProfile(userId)}
|
||||
/>
|
||||
<Menu.MenuItem
|
||||
id={cl("open-user-chat")}
|
||||
label="Open User Chat"
|
||||
action={() => openPrivateChannel(userId)}
|
||||
/>
|
||||
<Menu.MenuItem
|
||||
id={cl("copy-user-id")}
|
||||
label="Copy ID"
|
||||
action={() => copyWithToast(userCache!.id)}
|
||||
/>
|
||||
{
|
||||
!pending &&
|
||||
(
|
||||
<>
|
||||
<Menu.MenuItem
|
||||
id={cl("copy-user-globalname")}
|
||||
label="Copy Display Name"
|
||||
action={() => copyWithToast(userCache!.globalName ?? userCache!.username)}
|
||||
/>
|
||||
<Menu.MenuItem
|
||||
id={cl("copy-user-username")}
|
||||
label="Copy Username"
|
||||
action={() => copyWithToast(userCache!.username)}
|
||||
/>
|
||||
<Menu.MenuItem
|
||||
id={cl("copy-user-avatar")}
|
||||
label="Copy Avatar URL"
|
||||
action={() => copyWithToast(userCache!.avatar)}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
<Menu.MenuItem
|
||||
id={cl("copy-user-notes")}
|
||||
label="Copy Note"
|
||||
action={() => copyWithToast(userNotes)}
|
||||
/>
|
||||
</Menu.Menu>
|
||||
);
|
||||
}}
|
||||
>
|
||||
{
|
||||
pending ? <LoadingSpinner /> :
|
||||
<Avatar
|
||||
className={cl("user-avatar")}
|
||||
size="SIZE_56"
|
||||
src={userCache.avatar}
|
||||
/>
|
||||
}
|
||||
<div className={cl("user-info")} style={{
|
||||
minWidth: "50px",
|
||||
maxWidth: "275px",
|
||||
width: "100%",
|
||||
}}>
|
||||
<Text className={cl("user-info-globalname")} variant="text-lg/bold" style={{
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
color: "#fff"
|
||||
}}>{userCache.globalName}</Text>
|
||||
<Text className={cl("user-info-username")} variant="text-md/normal" style={{
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
color: "#d3d3d3"
|
||||
}}>{userCache.username}</Text>
|
||||
<Text className={cl("user-info-id")} variant="text-md/normal" style={{
|
||||
textOverflow: "ellipsis",
|
||||
overflow: "hidden",
|
||||
whiteSpace: "nowrap",
|
||||
color: "#989898"
|
||||
}}>{userCache.id}</Text>
|
||||
</div>
|
||||
<div className={cl("user-notes-container")} style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "calc(100% - 86px) min-content",
|
||||
alignItems: "center",
|
||||
justifyContent: "flex-end",
|
||||
flexGrow: "1",
|
||||
paddingRight: "8px",
|
||||
gap: "8px",
|
||||
}}>
|
||||
<TextArea
|
||||
className={cl("user-text-area")}
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
}}
|
||||
placeholder="Click to add a note"
|
||||
value={userNotes}
|
||||
onChange={setUserNotes}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<div className={cl("user-actions")} style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "auto auto",
|
||||
gridTemplateRows: "auto auto",
|
||||
gap: "3px",
|
||||
aspectRatio: "1 / 1",
|
||||
height: "auto",
|
||||
boxSizing: "border-box",
|
||||
overflow: "visible !important",
|
||||
}}>
|
||||
<Tooltip text={"Save"}>
|
||||
{({ onMouseLeave, onMouseEnter }) => (
|
||||
<Button
|
||||
className={cl("user-actions-save")}
|
||||
size={Button.Sizes.NONE}
|
||||
color={Button.Colors.GREEN}
|
||||
style={{ width: "32px", height: "32px" }}
|
||||
onClick={() => {
|
||||
updateNote(userId, userNotes);
|
||||
refreshNotesData();
|
||||
}}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onMouseEnter={onMouseEnter}
|
||||
>
|
||||
<SaveIcon />
|
||||
</Button>
|
||||
)}
|
||||
</Tooltip>
|
||||
<Tooltip text={"Delete"}>
|
||||
{({ onMouseLeave, onMouseEnter }) => (
|
||||
<Button
|
||||
className={cl("user-actions-delete")}
|
||||
size={Button.Sizes.NONE}
|
||||
color={Button.Colors.RED}
|
||||
style={{ width: "32px", height: "32px" }}
|
||||
onClick={() => {
|
||||
Alerts.show({
|
||||
title: "Delete Notes",
|
||||
body: `Are you sure you want to delete notes for ${pending ? userId : `${userCache!.globalName} (${userId})`}?`,
|
||||
confirmColor: Button.Colors.RED,
|
||||
confirmText: "Delete",
|
||||
cancelText: "Cancel",
|
||||
onConfirm: () => {
|
||||
updateNote(userId, "");
|
||||
refreshNotesData();
|
||||
},
|
||||
});
|
||||
}}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onMouseEnter={onMouseEnter}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</Button>
|
||||
)}
|
||||
</Tooltip>
|
||||
<Tooltip text={"Undo text area changes"}>
|
||||
{({ onMouseLeave, onMouseEnter }) => (
|
||||
<Button
|
||||
className={cl("user-actions-refresh")}
|
||||
size={Button.Sizes.NONE}
|
||||
color={Button.Colors.LINK}
|
||||
style={{ width: "32px", height: "32px" }}
|
||||
onClick={() => setUserNotes(userNotesArg)}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onMouseEnter={onMouseEnter}
|
||||
>
|
||||
<RefreshIcon />
|
||||
</Button>
|
||||
)}
|
||||
</Tooltip>
|
||||
<Tooltip text={"Open User Profile"}>
|
||||
{({ onMouseLeave, onMouseEnter }) => (
|
||||
<Button
|
||||
className={cl("user-actions-popup")}
|
||||
size={Button.Sizes.NONE}
|
||||
color={Button.Colors.PRIMARY}
|
||||
style={{ width: "32px", height: "32px" }}
|
||||
onClick={async () => {
|
||||
openUserProfile(userId);
|
||||
}}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onMouseEnter={onMouseEnter}
|
||||
>
|
||||
<PopupIcon />
|
||||
</Button>
|
||||
)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
153
src/plugins/notesSearcher/data.ts
Normal file
153
src/plugins/notesSearcher/data.ts
Normal file
|
@ -0,0 +1,153 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { Constants, FluxDispatcher, GuildStore, RestAPI, SnowflakeUtils, UserStore, UserUtils } from "@webpack/common";
|
||||
import { waitForStore } from "webpack/common/internal";
|
||||
|
||||
import { refreshNotesData, refreshUsersCache } from "./components/NotesDataModal";
|
||||
import * as t from "./types";
|
||||
|
||||
let NoteStore: t.NoteStore;
|
||||
|
||||
waitForStore("NoteStore", s => NoteStore = s);
|
||||
|
||||
export const getNotes = () => {
|
||||
return NoteStore.getNotes();
|
||||
};
|
||||
|
||||
export const onNoteUpdate = () => {
|
||||
refreshNotesData();
|
||||
};
|
||||
|
||||
export const updateNote = (userId: string, note: string | null) => {
|
||||
RestAPI.put({
|
||||
url: Constants.Endpoints.NOTE(userId),
|
||||
body: { note: note },
|
||||
oldFormErrors: true
|
||||
});
|
||||
};
|
||||
|
||||
export const usersCache: t.UsersCache = new Map();
|
||||
|
||||
export const onUserUpdate = ({ user }: { user: t.User; }) => {
|
||||
if (!getNotes()[user.id]) return;
|
||||
|
||||
// doesn't have .getAvatarURL
|
||||
const userFromStore = UserStore.getUser(user.id);
|
||||
|
||||
if (!userFromStore) return;
|
||||
|
||||
cacheUser(userFromStore);
|
||||
};
|
||||
|
||||
const fetchUser = async (userId: string) => {
|
||||
for (let _ = 0; _ < 5; _++) {
|
||||
try {
|
||||
return await UserUtils.getUser(userId);
|
||||
} catch (error: any) {
|
||||
const wait = error?.body?.retry_after;
|
||||
|
||||
if (!wait) return;
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, wait * 1000 + 100));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const cacheUser = (user: t.User) => {
|
||||
usersCache.set(user.id, {
|
||||
id: user.id,
|
||||
globalName: user.globalName ?? user.username,
|
||||
username: user.username,
|
||||
avatar: user.getAvatarURL(void 0, void 0, false),
|
||||
});
|
||||
};
|
||||
|
||||
export const cacheUsers = async () => {
|
||||
const toRequest: string[] = [];
|
||||
|
||||
for (const userId of Object.keys(getNotes())) {
|
||||
const user = UserStore.getUser(userId);
|
||||
|
||||
if (user) {
|
||||
cacheUser(user);
|
||||
continue;
|
||||
}
|
||||
|
||||
toRequest.push(userId);
|
||||
}
|
||||
|
||||
if (usersCache.size >= Object.keys(getNotes()).length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sentNonce = SnowflakeUtils.fromTimestamp(Date.now());
|
||||
|
||||
const allGuildIds = Object.keys(GuildStore.getGuilds());
|
||||
let count = allGuildIds.length * Math.ceil(toRequest.length / 100);
|
||||
|
||||
const processed = new Set<string>();
|
||||
|
||||
const callback = async ({ chunks }) => {
|
||||
for (const chunk of chunks) {
|
||||
const { nonce, members }: {
|
||||
nonce: string;
|
||||
members: {
|
||||
user: t.User;
|
||||
}[];
|
||||
} = chunk;
|
||||
|
||||
if (nonce !== sentNonce) {
|
||||
return;
|
||||
}
|
||||
|
||||
members.forEach(({ user }) => {
|
||||
if (processed.has(user.id)) return;
|
||||
|
||||
processed.add(user.id);
|
||||
|
||||
cacheUser(UserStore.getUser(user.id));
|
||||
});
|
||||
|
||||
refreshUsersCache();
|
||||
|
||||
if (--count === 0) {
|
||||
FluxDispatcher.unsubscribe("GUILD_MEMBERS_CHUNK_BATCH", callback);
|
||||
|
||||
const userIds = Object.keys(getNotes());
|
||||
|
||||
if (usersCache.size !== userIds.length) {
|
||||
|
||||
for (const userId of userIds) {
|
||||
if (usersCache.has(userId)) continue;
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
const user = await fetchUser(userId);
|
||||
|
||||
if (user) {
|
||||
cacheUser(user);
|
||||
refreshUsersCache();
|
||||
}
|
||||
}
|
||||
|
||||
} else
|
||||
refreshUsersCache();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
FluxDispatcher.subscribe("GUILD_MEMBERS_CHUNK_BATCH", callback);
|
||||
|
||||
for (let i = 0; i < toRequest.length; i += 100) {
|
||||
FluxDispatcher.dispatch({
|
||||
type: "GUILD_MEMBERS_REQUEST",
|
||||
guildIds: allGuildIds,
|
||||
userIds: toRequest.slice(i, i + 100),
|
||||
nonce: sentNonce,
|
||||
});
|
||||
}
|
||||
};
|
128
src/plugins/notesSearcher/index.tsx
Normal file
128
src/plugins/notesSearcher/index.tsx
Normal file
|
@ -0,0 +1,128 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { disableStyle, enableStyle } from "@api/Styles";
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin from "@utils/types";
|
||||
import { FluxDispatcher } from "@webpack/common";
|
||||
|
||||
import { OpenNotesDataButton } from "./components/NotesDataButton";
|
||||
import { getNotes, onNoteUpdate, onUserUpdate } from "./data";
|
||||
import settings from "./settings";
|
||||
import styles from "./styles.css?managed";
|
||||
import { Notes } from "./types";
|
||||
|
||||
export default definePlugin({
|
||||
name: "NotesSearcher",
|
||||
description: "Allows you to open a modal with all of your notes and search through them by user ID, note text, and username",
|
||||
authors: [Devs.Vishnya],
|
||||
settings,
|
||||
patches: [
|
||||
{
|
||||
find: "toolbar:function",
|
||||
replacement: {
|
||||
match: /(function \i\(\i\){)(.{1,200}toolbar.{1,100}mobileToolbar)/,
|
||||
replace: "$1$self.addToolBarButton(arguments[0]);$2",
|
||||
},
|
||||
},
|
||||
{
|
||||
find: '="NoteStore",',
|
||||
replacement: [
|
||||
{
|
||||
match: /getNote\(\i\){return (\i)/,
|
||||
replace: "getNotes(){return $1}$&",
|
||||
},
|
||||
// not sure it won't break anything but should be fine
|
||||
{
|
||||
match: /CONNECTION_OPEN:\i,OVERLAY_INITIALIZE:\i,/,
|
||||
replace: "",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
find: ".REQUEST_GUILD_MEMBERS",
|
||||
replacement: {
|
||||
match: /\.send\(8,{(?!nonce)/,
|
||||
replace: "$&nonce:arguments[1].nonce,",
|
||||
},
|
||||
},
|
||||
{
|
||||
find: "GUILD_MEMBERS_REQUEST:",
|
||||
replacement: {
|
||||
match: /presences:!!(\i)\.presences(?!,nonce)/,
|
||||
replace: "$&,nonce:$1.nonce",
|
||||
},
|
||||
},
|
||||
{
|
||||
find: ".not_found",
|
||||
replacement: {
|
||||
match: /notFound:(\i)\.not_found(?!,nonce)/,
|
||||
replace: "$&,nonce:$1.nonce",
|
||||
},
|
||||
},
|
||||
{
|
||||
find: "[IDENTIFY]",
|
||||
replacement: {
|
||||
match: /capabilities:(\i\.\i),/,
|
||||
replace: "capabilities:$1&~1,",
|
||||
},
|
||||
},
|
||||
{
|
||||
find: "_handleDispatch",
|
||||
replacement: {
|
||||
match: /let \i=(\i).session_id;/,
|
||||
replace: "$&$self.ready($1);",
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
start: async () => {
|
||||
FluxDispatcher.subscribe("USER_NOTE_UPDATE", onNoteUpdate);
|
||||
FluxDispatcher.subscribe("USER_UPDATE", onUserUpdate);
|
||||
|
||||
enableStyle(styles);
|
||||
},
|
||||
stop: () => {
|
||||
FluxDispatcher.unsubscribe("USER_NOTE_UPDATE", onNoteUpdate);
|
||||
FluxDispatcher.unsubscribe("USER_UPDATE", onUserUpdate);
|
||||
|
||||
disableStyle(styles);
|
||||
},
|
||||
|
||||
ready: ({ notes }: { notes: { [userId: string]: string; }; }) => {
|
||||
const notesFromStore = getNotes();
|
||||
|
||||
for (const userId of Object.keys(notesFromStore)) {
|
||||
delete notesFromStore[userId];
|
||||
}
|
||||
|
||||
Object.assign(notesFromStore, Object.entries(notes).reduce((newNotes, [userId, note]) => {
|
||||
newNotes[userId] = {
|
||||
note,
|
||||
loading: false,
|
||||
};
|
||||
|
||||
return newNotes;
|
||||
}, {} as Notes));
|
||||
},
|
||||
|
||||
addToolBarButton: (children: { toolbar: React.ReactNode[] | React.ReactNode; }) => {
|
||||
if (Array.isArray(children.toolbar))
|
||||
return children.toolbar.push(
|
||||
<ErrorBoundary noop={true}>
|
||||
<OpenNotesDataButton />
|
||||
</ErrorBoundary>
|
||||
);
|
||||
|
||||
children.toolbar = [
|
||||
<ErrorBoundary noop={true}>
|
||||
<OpenNotesDataButton />
|
||||
</ErrorBoundary>,
|
||||
children.toolbar,
|
||||
];
|
||||
},
|
||||
});
|
16
src/plugins/notesSearcher/settings.tsx
Normal file
16
src/plugins/notesSearcher/settings.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import { OptionType } from "@utils/types";
|
||||
|
||||
export default definePluginSettings({
|
||||
startupCache: {
|
||||
default: false,
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Cache all users on startup",
|
||||
},
|
||||
});
|
107
src/plugins/notesSearcher/styles.css
Normal file
107
src/plugins/notesSearcher/styles.css
Normal file
|
@ -0,0 +1,107 @@
|
|||
.vc-notes-searcher-toolbox-button svg {
|
||||
color: var(--interactive-normal);
|
||||
}
|
||||
|
||||
.vc-notes-searcher-modal-user-actions * svg {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
}
|
||||
|
||||
.vc-notes-searcher-toolbox-button:hover svg,
|
||||
.vc-notes-searcher-toolbox-button[class*="selected"] svg {
|
||||
color: var(--interactive-active);
|
||||
}
|
||||
|
||||
.vc-notes-searcher-modal-root {
|
||||
min-height: 75vh;
|
||||
max-height: 75vh;
|
||||
min-width: 70vw;
|
||||
max-width: 70vw;
|
||||
}
|
||||
|
||||
.vc-notes-searcher-modal-header-input {
|
||||
width: 100%;
|
||||
margin-right: 16px;
|
||||
}
|
||||
|
||||
.vc-notes-searcher-modal-content {
|
||||
padding-bottom: 16px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.vc-notes-searcher-modal-content div[aria-hidden="true"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vc-notes-searcher-modal-content-inner > *:not(:last-child) {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.vc-notes-searcher-modal-user:hover:not(
|
||||
:has(
|
||||
.vc-notes-searcher-modal-user-text-area:hover,
|
||||
.vc-notes-searcher-modal-user-actions:hover
|
||||
)
|
||||
) {
|
||||
background-color: var(--background-secondary-alt);
|
||||
}
|
||||
|
||||
.vc-notes-searcher-modal-user-avatar {
|
||||
aspect-ratio: 1 / 1;
|
||||
margin: 12px;
|
||||
}
|
||||
|
||||
.vc-notes-searcher-modal-user-actions * div:has(svg) {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
|
||||
.vc-notes-searcher-modal-user-notes-container
|
||||
*
|
||||
div:has(.vc-notes-searcher-modal-user-text-area) {
|
||||
height: 67px;
|
||||
}
|
||||
|
||||
.vc-notes-searcher-modal-user-text-area {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.vc-notes-searcher-modal-spinner::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border: 5px solid #fff;
|
||||
border-radius: 50%;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
animation: vc-notes-searcher-scale-up 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes vc-notes-searcher-scale-up {
|
||||
0% {
|
||||
transform: translate(-50%, -50%) scale(0);
|
||||
}
|
||||
|
||||
60%,
|
||||
100% {
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes vc-notes-searcher-pulse {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
80% {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
}
|
37
src/plugins/notesSearcher/types.d.ts
vendored
Normal file
37
src/plugins/notesSearcher/types.d.ts
vendored
Normal file
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { FluxStore } from "@webpack/types";
|
||||
import { User as User$1 } from "discord-types/general";
|
||||
|
||||
export type Note = {
|
||||
loading: boolean;
|
||||
note: string;
|
||||
};
|
||||
|
||||
export type Notes = {
|
||||
[userId: string]: Note;
|
||||
};
|
||||
|
||||
export class NoteStore extends FluxStore {
|
||||
getNotes(): Notes;
|
||||
getNote(userId: string): Note;
|
||||
}
|
||||
|
||||
export type User = User$1 & {
|
||||
globalName?: string;
|
||||
};
|
||||
|
||||
export type Dispatch = ReturnType<typeof useState<any>>[1];
|
||||
|
||||
export type UserCache = {
|
||||
id: string;
|
||||
globalName?: string;
|
||||
username: string;
|
||||
avatar: string;
|
||||
};
|
||||
|
||||
export type UsersCache = Map<string, UserCache>;
|
7
src/plugins/userVoiceShow/README.md
Normal file
7
src/plugins/userVoiceShow/README.md
Normal file
|
@ -0,0 +1,7 @@
|
|||
# User Voice Show
|
||||
|
||||
Shows an indicator when a user is in a Voice Channel
|
||||
|
||||
![a preview of the indicator in the user profile](https://github.com/user-attachments/assets/48f825e4-fad5-40d7-bb4f-41d5e595aae0)
|
||||
|
||||
![a preview of the indicator in the member list](https://github.com/user-attachments/assets/51be081d-7bbb-45c5-8533-d565228e50c1)
|
170
src/plugins/userVoiceShow/components.tsx
Normal file
170
src/plugins/userVoiceShow/components.tsx
Normal file
|
@ -0,0 +1,170 @@
|
|||
/*
|
||||
* Vencord, a Discord client mod
|
||||
* Copyright (c) 2024 Vendicated and contributors
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||
*/
|
||||
|
||||
import { classNameFactory } from "@api/Styles";
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { classes } from "@utils/misc";
|
||||
import { findByPropsLazy, findComponentByCodeLazy, findStoreLazy } from "@webpack";
|
||||
import { ChannelStore, GuildStore, IconUtils, NavigationRouter, PermissionsBits, PermissionStore, showToast, Text, Toasts, Tooltip, useCallback, useMemo, UserStore, useStateFromStores } from "@webpack/common";
|
||||
import { Channel } from "discord-types/general";
|
||||
|
||||
const cl = classNameFactory("vc-uvs-");
|
||||
|
||||
const { selectVoiceChannel } = findByPropsLazy("selectChannel", "selectVoiceChannel");
|
||||
const VoiceStateStore = findStoreLazy("VoiceStateStore");
|
||||
const UserSummaryItem = findComponentByCodeLazy("defaultRenderUser", "showDefaultAvatarsForNullUsers");
|
||||
|
||||
interface IconProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
size?: number;
|
||||
}
|
||||
|
||||
function SpeakerIcon(props: IconProps) {
|
||||
props.size ??= 16;
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
role={props.onClick != null ? "button" : undefined}
|
||||
className={classes(cl("speaker"), props.onClick != null ? cl("clickable") : undefined)}
|
||||
>
|
||||
<svg
|
||||
width={props.size}
|
||||
height={props.size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path d="M12 3a1 1 0 0 0-1-1h-.06a1 1 0 0 0-.74.32L5.92 7H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h2.92l4.28 4.68a1 1 0 0 0 .74.32H11a1 1 0 0 0 1-1V3ZM15.1 20.75c-.58.14-1.1-.33-1.1-.92v-.03c0-.5.37-.92.85-1.05a7 7 0 0 0 0-13.5A1.11 1.11 0 0 1 14 4.2v-.03c0-.6.52-1.06 1.1-.92a9 9 0 0 1 0 17.5Z" />
|
||||
<path d="M15.16 16.51c-.57.28-1.16-.2-1.16-.83v-.14c0-.43.28-.8.63-1.02a3 3 0 0 0 0-5.04c-.35-.23-.63-.6-.63-1.02v-.14c0-.63.59-1.1 1.16-.83a5 5 0 0 1 0 9.02Z" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LockedSpeakerIcon(props: IconProps) {
|
||||
props.size ??= 16;
|
||||
|
||||
return (
|
||||
<div
|
||||
{...props}
|
||||
role={props.onClick != null ? "button" : undefined}
|
||||
className={classes(cl("speaker"), props.onClick != null ? cl("clickable") : undefined)}
|
||||
>
|
||||
<svg
|
||||
width={props.size}
|
||||
height={props.size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path fillRule="evenodd" clipRule="evenodd" d="M16 4h.5v-.5a2.5 2.5 0 0 1 5 0V4h.5a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1h-6a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Zm4-.5V4h-2v-.5a1 1 0 1 1 2 0Z" />
|
||||
<path d="M11 2a1 1 0 0 1 1 1v18a1 1 0 0 1-1 1h-.06a1 1 0 0 1-.74-.32L5.92 17H3a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1h2.92l4.28-4.68a1 1 0 0 1 .74-.32H11ZM20.5 12c-.28 0-.5.22-.52.5a7 7 0 0 1-5.13 6.25c-.48.13-.85.55-.85 1.05v.03c0 .6.52 1.06 1.1.92a9 9 0 0 0 6.89-8.25.48.48 0 0 0-.49-.5h-1ZM16.5 12c-.28 0-.5.23-.54.5a3 3 0 0 1-1.33 2.02c-.35.23-.63.6-.63 1.02v.14c0 .63.59 1.1 1.16.83a5 5 0 0 0 2.82-4.01c.02-.28-.2-.5-.48-.5h-1Z" />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface VoiceChannelTooltipProps {
|
||||
channel: Channel;
|
||||
}
|
||||
|
||||
function VoiceChannelTooltip({ channel }: VoiceChannelTooltipProps) {
|
||||
const voiceStates = useStateFromStores([VoiceStateStore], () => VoiceStateStore.getVoiceStatesForChannel(channel.id));
|
||||
const users = useMemo(
|
||||
() => Object.values<any>(voiceStates).map(voiceState => UserStore.getUser(voiceState.userId)).filter(user => user != null),
|
||||
[voiceStates]
|
||||
);
|
||||
|
||||
const guild = useMemo(
|
||||
() => channel.getGuildId() == null ? undefined : GuildStore.getGuild(channel.getGuildId()),
|
||||
[channel]
|
||||
);
|
||||
|
||||
const guildIcon = useMemo(() => {
|
||||
return guild?.icon == null ? undefined : IconUtils.getGuildIconURL({
|
||||
id: guild.id,
|
||||
icon: guild.icon,
|
||||
size: 30
|
||||
});
|
||||
}, [guild]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{guild != null && (
|
||||
<div className={cl("guild-name")}>
|
||||
{guildIcon != null && <img className={cl("guild-icon")} src={guildIcon} alt="" />}
|
||||
<Text variant="text-sm/bold">{guild.name}</Text>
|
||||
</div>
|
||||
)}
|
||||
<Text variant="text-sm/semibold">{channel.name}</Text>
|
||||
<div className={cl("vc-members")}>
|
||||
<SpeakerIcon size={18} />
|
||||
<UserSummaryItem
|
||||
users={users}
|
||||
renderIcon={false}
|
||||
max={7}
|
||||
size={18}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface VoiceChannelIndicatorProps {
|
||||
userId: string;
|
||||
}
|
||||
|
||||
const clickTimers = {} as Record<string, any>;
|
||||
|
||||
export const VoiceChannelIndicator = ErrorBoundary.wrap(({ userId }: VoiceChannelIndicatorProps) => {
|
||||
const channelId = useStateFromStores([VoiceStateStore], () => VoiceStateStore.getVoiceStateForUser(userId)?.channelId as string | undefined);
|
||||
const channel = useMemo(() => channelId == null ? undefined : ChannelStore.getChannel(channelId), [channelId]);
|
||||
|
||||
const onClick = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (channel == null || channelId == null) return;
|
||||
|
||||
if (!PermissionStore.can(PermissionsBits.VIEW_CHANNEL, channel)) {
|
||||
showToast("You cannot view the user's Voice Channel", Toasts.Type.FAILURE);
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(clickTimers[channelId]);
|
||||
delete clickTimers[channelId];
|
||||
|
||||
if (e.detail > 1) {
|
||||
if (!PermissionStore.can(PermissionsBits.CONNECT, channel)) {
|
||||
showToast("You cannot join the user's Voice Channel", Toasts.Type.FAILURE);
|
||||
return;
|
||||
}
|
||||
|
||||
selectVoiceChannel(channelId);
|
||||
} else {
|
||||
clickTimers[channelId] = setTimeout(() => {
|
||||
NavigationRouter.transitionTo(`/channels/${channel.getGuildId() ?? "@me"}/${channelId}`);
|
||||
delete clickTimers[channelId];
|
||||
}, 250);
|
||||
}
|
||||
}, [channelId]);
|
||||
|
||||
const isLocked = useMemo(() => {
|
||||
return !PermissionStore.can(PermissionsBits.VIEW_CHANNEL, channel) || !PermissionStore.can(PermissionsBits.CONNECT, channel);
|
||||
}, [channelId]);
|
||||
|
||||
if (channel == null) return null;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
text={<VoiceChannelTooltip channel={channel} />}
|
||||
tooltipClassName={cl("tooltip-container")}
|
||||
>
|
||||
{props =>
|
||||
isLocked ?
|
||||
<LockedSpeakerIcon {...props} onClick={onClick} />
|
||||
: <SpeakerIcon {...props} onClick={onClick} />
|
||||
}
|
||||
</Tooltip>
|
||||
);
|
||||
}, { noop: true });
|
|
@ -1,27 +0,0 @@
|
|||
.vc-uvs-button>div {
|
||||
white-space: normal !important;
|
||||
}
|
||||
|
||||
.vc-uvs-button {
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
height: unset;
|
||||
}
|
||||
|
||||
.vc-uvs-header {
|
||||
color: var(--header-primary);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.vc-uvs-modal-margin {
|
||||
margin: 0 12px;
|
||||
}
|
||||
|
||||
.vc-uvs-modal-margin div {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.vc-uvs-popout-margin-self>[class^="section"] {
|
||||
padding-top: 0;
|
||||
padding-bottom: 12px;
|
||||
}
|
|
@ -1,61 +0,0 @@
|
|||
/*
|
||||
* Vencord, a modification for Discord's desktop app
|
||||
* Copyright (c) 2023 Vendicated and contributors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import "./VoiceChannelSection.css";
|
||||
|
||||
import { findByPropsLazy } from "@webpack";
|
||||
import { Button, Forms, PermissionStore, Toasts } from "@webpack/common";
|
||||
import { Channel } from "discord-types/general";
|
||||
|
||||
const ChannelActions = findByPropsLazy("selectChannel", "selectVoiceChannel");
|
||||
|
||||
const CONNECT = 1n << 20n;
|
||||
|
||||
interface VoiceChannelFieldProps {
|
||||
channel: Channel;
|
||||
label: string;
|
||||
showHeader: boolean;
|
||||
}
|
||||
|
||||
export const VoiceChannelSection = ({ channel, label, showHeader }: VoiceChannelFieldProps) => (
|
||||
// @TODO The div is supposed to be a UserPopoutSection
|
||||
<div>
|
||||
{showHeader && <Forms.FormTitle className="vc-uvs-header">In a voice channel</Forms.FormTitle>}
|
||||
<Button
|
||||
className="vc-uvs-button"
|
||||
color={Button.Colors.TRANSPARENT}
|
||||
size={Button.Sizes.SMALL}
|
||||
|
||||
onClick={() => {
|
||||
if (PermissionStore.can(CONNECT, channel))
|
||||
ChannelActions.selectVoiceChannel(channel.id);
|
||||
else
|
||||
Toasts.show({
|
||||
message: "Insufficient permissions to enter the channel.",
|
||||
id: "user-voice-show-insufficient-permissions",
|
||||
type: Toasts.Type.FAILURE,
|
||||
options: {
|
||||
position: Toasts.Position.BOTTOM,
|
||||
}
|
||||
});
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
|
@ -16,85 +16,85 @@
|
|||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import "./style.css";
|
||||
|
||||
import { addDecorator, removeDecorator } from "@api/MemberListDecorators";
|
||||
import { definePluginSettings } from "@api/Settings";
|
||||
import ErrorBoundary from "@components/ErrorBoundary";
|
||||
import { Devs } from "@utils/constants";
|
||||
import definePlugin, { OptionType } from "@utils/types";
|
||||
import { findStoreLazy } from "@webpack";
|
||||
import { ChannelStore, GuildStore, UserStore } from "@webpack/common";
|
||||
import { User } from "discord-types/general";
|
||||
|
||||
import { VoiceChannelSection } from "./components/VoiceChannelSection";
|
||||
|
||||
const VoiceStateStore = findStoreLazy("VoiceStateStore");
|
||||
import { VoiceChannelIndicator } from "./components";
|
||||
|
||||
const settings = definePluginSettings({
|
||||
showInUserProfileModal: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: "Show a user's voice channel in their profile modal",
|
||||
description: "Show a user's Voice Channel indicator in their profile next to the name",
|
||||
default: true,
|
||||
restartNeeded: true
|
||||
},
|
||||
showVoiceChannelSectionHeader: {
|
||||
showInVoiceMemberList: {
|
||||
type: OptionType.BOOLEAN,
|
||||
description: 'Whether to show "IN A VOICE CHANNEL" above the join button',
|
||||
description: "Show a user's Voice Channel indicator in the member and DMs list",
|
||||
default: true,
|
||||
restartNeeded: true
|
||||
}
|
||||
});
|
||||
|
||||
interface UserProps {
|
||||
user: User;
|
||||
}
|
||||
|
||||
const VoiceChannelField = ErrorBoundary.wrap(({ user }: UserProps) => {
|
||||
const { channelId } = VoiceStateStore.getVoiceStateForUser(user.id) ?? {};
|
||||
if (!channelId) return null;
|
||||
|
||||
const channel = ChannelStore.getChannel(channelId);
|
||||
if (!channel) return null;
|
||||
|
||||
const guild = GuildStore.getGuild(channel.guild_id);
|
||||
|
||||
if (!guild) return null; // When in DM call
|
||||
|
||||
const result = `${guild.name} | ${channel.name}`;
|
||||
|
||||
return (
|
||||
<VoiceChannelSection
|
||||
channel={channel}
|
||||
label={result}
|
||||
showHeader={settings.store.showVoiceChannelSectionHeader}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export default definePlugin({
|
||||
name: "UserVoiceShow",
|
||||
description: "Shows whether a User is currently in a voice channel somewhere in their profile",
|
||||
authors: [Devs.LordElias],
|
||||
description: "Shows an indicator when a user is in a Voice Channel",
|
||||
authors: [Devs.LordElias, Devs.Nuckyz],
|
||||
settings,
|
||||
|
||||
patchModal({ user }: UserProps) {
|
||||
if (!settings.store.showInUserProfileModal)
|
||||
return null;
|
||||
|
||||
return (
|
||||
<div className="vc-uvs-modal-margin">
|
||||
<VoiceChannelField user={user} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
patchProfilePopout: ({ user }: UserProps) => {
|
||||
const isSelfUser = user.id === UserStore.getCurrentUser().id;
|
||||
return (
|
||||
<div className={isSelfUser ? "vc-uvs-popout-margin-self" : ""}>
|
||||
<VoiceChannelField user={user} />
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
||||
patches: [
|
||||
// @TODO Maybe patch UserVoiceShow in simplified profile popout
|
||||
// @TODO Patch new profile modal
|
||||
// User Popout, Full Size Profile, Direct Messages Side Profile
|
||||
{
|
||||
find: ".Messages.USER_PROFILE_LOAD_ERROR",
|
||||
replacement: {
|
||||
match: /(\.fetchError.+?\?)null/,
|
||||
replace: (_, rest) => `${rest}$self.VoiceChannelIndicator({userId:arguments[0]?.userId})`
|
||||
},
|
||||
predicate: () => settings.store.showInUserProfileModal
|
||||
},
|
||||
// To use without the MemberList decorator API
|
||||
/* // Guild Members List
|
||||
{
|
||||
find: ".lostPermission)",
|
||||
replacement: {
|
||||
match: /\.lostPermission\).+?(?=avatar:)/,
|
||||
replace: "$&children:[$self.VoiceChannelIndicator({userId:arguments[0]?.user?.id})],"
|
||||
},
|
||||
predicate: () => settings.store.showVoiceChannelIndicator
|
||||
},
|
||||
// Direct Messages List
|
||||
{
|
||||
find: "PrivateChannel.renderAvatar",
|
||||
replacement: {
|
||||
match: /\.Messages\.CLOSE_DM.+?}\)(?=])/,
|
||||
replace: "$&,$self.VoiceChannelIndicator({userId:arguments[0]?.user?.id})"
|
||||
},
|
||||
predicate: () => settings.store.showVoiceChannelIndicator
|
||||
}, */
|
||||
// Friends List
|
||||
{
|
||||
find: ".avatar,animate:",
|
||||
replacement: {
|
||||
match: /\.subtext,children:.+?}\)\]}\)(?=])/,
|
||||
replace: "$&,$self.VoiceChannelIndicator({userId:arguments[0]?.user?.id})"
|
||||
},
|
||||
predicate: () => settings.store.showInVoiceMemberList
|
||||
}
|
||||
],
|
||||
|
||||
start() {
|
||||
if (settings.store.showInVoiceMemberList) {
|
||||
addDecorator("UserVoiceShow", ({ user }) => user == null ? null : <VoiceChannelIndicator userId={user.id} />);
|
||||
}
|
||||
},
|
||||
|
||||
stop() {
|
||||
removeDecorator("UserVoiceShow");
|
||||
},
|
||||
|
||||
VoiceChannelIndicator
|
||||
});
|
||||
|
|
37
src/plugins/userVoiceShow/style.css
Normal file
37
src/plugins/userVoiceShow/style.css
Normal file
|
@ -0,0 +1,37 @@
|
|||
.vc-uvs-speaker {
|
||||
color: var(--interactive-normal);
|
||||
padding: 0 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.vc-uvs-clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.vc-uvs-clickable:hover {
|
||||
color: var(--interactive-hover);
|
||||
}
|
||||
|
||||
.vc-uvs-tooltip-container {
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.vc-uvs-guild-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vc-uvs-guild-icon {
|
||||
border-radius: 100%;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.vc-uvs-vc-members {
|
||||
display: flex;
|
||||
margin: 8px 0;
|
||||
flex-direction: row;
|
||||
gap: 6px;
|
||||
}
|
Loading…
Reference in a new issue