mirror of
https://github.com/stoatchat/for-legacy-web.git
synced 2026-03-07 01:15:28 +00:00
Use tabWidth 4 without actual tabs.
This commit is contained in:
@@ -12,464 +12,464 @@ import Emoji from "./Emoji";
|
||||
import UserIcon from "./user/UserIcon";
|
||||
|
||||
export type AutoCompleteState =
|
||||
| { type: "none" }
|
||||
| ({ selected: number; within: boolean } & (
|
||||
| {
|
||||
type: "emoji";
|
||||
matches: string[];
|
||||
}
|
||||
| {
|
||||
type: "user";
|
||||
matches: User[];
|
||||
}
|
||||
| {
|
||||
type: "channel";
|
||||
matches: Channels.TextChannel[];
|
||||
}
|
||||
));
|
||||
| { type: "none" }
|
||||
| ({ selected: number; within: boolean } & (
|
||||
| {
|
||||
type: "emoji";
|
||||
matches: string[];
|
||||
}
|
||||
| {
|
||||
type: "user";
|
||||
matches: User[];
|
||||
}
|
||||
| {
|
||||
type: "channel";
|
||||
matches: Channels.TextChannel[];
|
||||
}
|
||||
));
|
||||
|
||||
export type SearchClues = {
|
||||
users?: { type: "channel"; id: string } | { type: "all" };
|
||||
channels?: { server: string };
|
||||
users?: { type: "channel"; id: string } | { type: "all" };
|
||||
channels?: { server: string };
|
||||
};
|
||||
|
||||
export type AutoCompleteProps = {
|
||||
detached?: boolean;
|
||||
state: AutoCompleteState;
|
||||
setState: StateUpdater<AutoCompleteState>;
|
||||
detached?: boolean;
|
||||
state: AutoCompleteState;
|
||||
setState: StateUpdater<AutoCompleteState>;
|
||||
|
||||
onKeyUp: (ev: KeyboardEvent) => void;
|
||||
onKeyDown: (ev: KeyboardEvent) => boolean;
|
||||
onChange: (ev: JSX.TargetedEvent<HTMLTextAreaElement, Event>) => void;
|
||||
onClick: JSX.MouseEventHandler<HTMLButtonElement>;
|
||||
onFocus: JSX.FocusEventHandler<HTMLTextAreaElement>;
|
||||
onBlur: JSX.FocusEventHandler<HTMLTextAreaElement>;
|
||||
onKeyUp: (ev: KeyboardEvent) => void;
|
||||
onKeyDown: (ev: KeyboardEvent) => boolean;
|
||||
onChange: (ev: JSX.TargetedEvent<HTMLTextAreaElement, Event>) => void;
|
||||
onClick: JSX.MouseEventHandler<HTMLButtonElement>;
|
||||
onFocus: JSX.FocusEventHandler<HTMLTextAreaElement>;
|
||||
onBlur: JSX.FocusEventHandler<HTMLTextAreaElement>;
|
||||
};
|
||||
|
||||
export function useAutoComplete(
|
||||
setValue: (v?: string) => void,
|
||||
searchClues?: SearchClues,
|
||||
setValue: (v?: string) => void,
|
||||
searchClues?: SearchClues,
|
||||
): AutoCompleteProps {
|
||||
const [state, setState] = useState<AutoCompleteState>({ type: "none" });
|
||||
const [focused, setFocused] = useState(false);
|
||||
const client = useContext(AppContext);
|
||||
const [state, setState] = useState<AutoCompleteState>({ type: "none" });
|
||||
const [focused, setFocused] = useState(false);
|
||||
const client = useContext(AppContext);
|
||||
|
||||
function findSearchString(
|
||||
el: HTMLTextAreaElement,
|
||||
): ["emoji" | "user" | "channel", string, number] | undefined {
|
||||
if (el.selectionStart === el.selectionEnd) {
|
||||
let cursor = el.selectionStart;
|
||||
let content = el.value.slice(0, cursor);
|
||||
function findSearchString(
|
||||
el: HTMLTextAreaElement,
|
||||
): ["emoji" | "user" | "channel", string, number] | undefined {
|
||||
if (el.selectionStart === el.selectionEnd) {
|
||||
let cursor = el.selectionStart;
|
||||
let content = el.value.slice(0, cursor);
|
||||
|
||||
let valid = /\w/;
|
||||
let valid = /\w/;
|
||||
|
||||
let j = content.length - 1;
|
||||
if (content[j] === "@") {
|
||||
return ["user", "", j];
|
||||
} else if (content[j] === "#") {
|
||||
return ["channel", "", j];
|
||||
}
|
||||
let j = content.length - 1;
|
||||
if (content[j] === "@") {
|
||||
return ["user", "", j];
|
||||
} else if (content[j] === "#") {
|
||||
return ["channel", "", j];
|
||||
}
|
||||
|
||||
while (j >= 0 && valid.test(content[j])) {
|
||||
j--;
|
||||
}
|
||||
while (j >= 0 && valid.test(content[j])) {
|
||||
j--;
|
||||
}
|
||||
|
||||
if (j === -1) return;
|
||||
let current = content[j];
|
||||
if (j === -1) return;
|
||||
let current = content[j];
|
||||
|
||||
if (current === ":" || current === "@" || current === "#") {
|
||||
let search = content.slice(j + 1, content.length);
|
||||
if (search.length > 0) {
|
||||
return [
|
||||
current === "#"
|
||||
? "channel"
|
||||
: current === ":"
|
||||
? "emoji"
|
||||
: "user",
|
||||
search.toLowerCase(),
|
||||
j + 1,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (current === ":" || current === "@" || current === "#") {
|
||||
let search = content.slice(j + 1, content.length);
|
||||
if (search.length > 0) {
|
||||
return [
|
||||
current === "#"
|
||||
? "channel"
|
||||
: current === ":"
|
||||
? "emoji"
|
||||
: "user",
|
||||
search.toLowerCase(),
|
||||
j + 1,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onChange(ev: JSX.TargetedEvent<HTMLTextAreaElement, Event>) {
|
||||
const el = ev.currentTarget;
|
||||
function onChange(ev: JSX.TargetedEvent<HTMLTextAreaElement, Event>) {
|
||||
const el = ev.currentTarget;
|
||||
|
||||
let result = findSearchString(el);
|
||||
if (result) {
|
||||
let [type, search] = result;
|
||||
const regex = new RegExp(search, "i");
|
||||
let result = findSearchString(el);
|
||||
if (result) {
|
||||
let [type, search] = result;
|
||||
const regex = new RegExp(search, "i");
|
||||
|
||||
if (type === "emoji") {
|
||||
// ! FIXME: we should convert it to a Binary Search Tree and use that
|
||||
let matches = Object.keys(emojiDictionary)
|
||||
.filter((emoji: string) => emoji.match(regex))
|
||||
.splice(0, 5);
|
||||
if (type === "emoji") {
|
||||
// ! FIXME: we should convert it to a Binary Search Tree and use that
|
||||
let matches = Object.keys(emojiDictionary)
|
||||
.filter((emoji: string) => emoji.match(regex))
|
||||
.splice(0, 5);
|
||||
|
||||
if (matches.length > 0) {
|
||||
let currentPosition =
|
||||
state.type !== "none" ? state.selected : 0;
|
||||
if (matches.length > 0) {
|
||||
let currentPosition =
|
||||
state.type !== "none" ? state.selected : 0;
|
||||
|
||||
setState({
|
||||
type: "emoji",
|
||||
matches,
|
||||
selected: Math.min(currentPosition, matches.length - 1),
|
||||
within: false,
|
||||
});
|
||||
setState({
|
||||
type: "emoji",
|
||||
matches,
|
||||
selected: Math.min(currentPosition, matches.length - 1),
|
||||
within: false,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "user" && searchClues?.users) {
|
||||
let users: User[] = [];
|
||||
switch (searchClues.users.type) {
|
||||
case "all":
|
||||
users = client.users.toArray();
|
||||
break;
|
||||
case "channel": {
|
||||
let channel = client.channels.get(searchClues.users.id);
|
||||
switch (channel?.channel_type) {
|
||||
case "Group":
|
||||
case "DirectMessage":
|
||||
users = client.users
|
||||
.mapKeys(channel.recipients)
|
||||
.filter(
|
||||
(x) => typeof x !== "undefined",
|
||||
) as User[];
|
||||
break;
|
||||
case "TextChannel":
|
||||
const server = channel.server;
|
||||
users = client.servers.members
|
||||
.toArray()
|
||||
.filter(
|
||||
(x) => x._id.substr(0, 26) === server,
|
||||
)
|
||||
.map((x) =>
|
||||
client.users.get(x._id.substr(26)),
|
||||
)
|
||||
.filter(
|
||||
(x) => typeof x !== "undefined",
|
||||
) as User[];
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (type === "user" && searchClues?.users) {
|
||||
let users: User[] = [];
|
||||
switch (searchClues.users.type) {
|
||||
case "all":
|
||||
users = client.users.toArray();
|
||||
break;
|
||||
case "channel": {
|
||||
let channel = client.channels.get(searchClues.users.id);
|
||||
switch (channel?.channel_type) {
|
||||
case "Group":
|
||||
case "DirectMessage":
|
||||
users = client.users
|
||||
.mapKeys(channel.recipients)
|
||||
.filter(
|
||||
(x) => typeof x !== "undefined",
|
||||
) as User[];
|
||||
break;
|
||||
case "TextChannel":
|
||||
const server = channel.server;
|
||||
users = client.servers.members
|
||||
.toArray()
|
||||
.filter(
|
||||
(x) => x._id.substr(0, 26) === server,
|
||||
)
|
||||
.map((x) =>
|
||||
client.users.get(x._id.substr(26)),
|
||||
)
|
||||
.filter(
|
||||
(x) => typeof x !== "undefined",
|
||||
) as User[];
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
users = users.filter((x) => x._id !== SYSTEM_USER_ID);
|
||||
users = users.filter((x) => x._id !== SYSTEM_USER_ID);
|
||||
|
||||
let matches = (
|
||||
search.length > 0
|
||||
? users.filter((user) =>
|
||||
user.username.toLowerCase().match(regex),
|
||||
)
|
||||
: users
|
||||
)
|
||||
.splice(0, 5)
|
||||
.filter((x) => typeof x !== "undefined");
|
||||
let matches = (
|
||||
search.length > 0
|
||||
? users.filter((user) =>
|
||||
user.username.toLowerCase().match(regex),
|
||||
)
|
||||
: users
|
||||
)
|
||||
.splice(0, 5)
|
||||
.filter((x) => typeof x !== "undefined");
|
||||
|
||||
if (matches.length > 0) {
|
||||
let currentPosition =
|
||||
state.type !== "none" ? state.selected : 0;
|
||||
if (matches.length > 0) {
|
||||
let currentPosition =
|
||||
state.type !== "none" ? state.selected : 0;
|
||||
|
||||
setState({
|
||||
type: "user",
|
||||
matches,
|
||||
selected: Math.min(currentPosition, matches.length - 1),
|
||||
within: false,
|
||||
});
|
||||
setState({
|
||||
type: "user",
|
||||
matches,
|
||||
selected: Math.min(currentPosition, matches.length - 1),
|
||||
within: false,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (type === "channel" && searchClues?.channels) {
|
||||
let channels = client.servers
|
||||
.get(searchClues.channels.server)
|
||||
?.channels.map((x) => client.channels.get(x))
|
||||
.filter(
|
||||
(x) => typeof x !== "undefined",
|
||||
) as Channels.TextChannel[];
|
||||
if (type === "channel" && searchClues?.channels) {
|
||||
let channels = client.servers
|
||||
.get(searchClues.channels.server)
|
||||
?.channels.map((x) => client.channels.get(x))
|
||||
.filter(
|
||||
(x) => typeof x !== "undefined",
|
||||
) as Channels.TextChannel[];
|
||||
|
||||
let matches = (
|
||||
search.length > 0
|
||||
? channels.filter((channel) =>
|
||||
channel.name.toLowerCase().match(regex),
|
||||
)
|
||||
: channels
|
||||
)
|
||||
.splice(0, 5)
|
||||
.filter((x) => typeof x !== "undefined");
|
||||
let matches = (
|
||||
search.length > 0
|
||||
? channels.filter((channel) =>
|
||||
channel.name.toLowerCase().match(regex),
|
||||
)
|
||||
: channels
|
||||
)
|
||||
.splice(0, 5)
|
||||
.filter((x) => typeof x !== "undefined");
|
||||
|
||||
if (matches.length > 0) {
|
||||
let currentPosition =
|
||||
state.type !== "none" ? state.selected : 0;
|
||||
if (matches.length > 0) {
|
||||
let currentPosition =
|
||||
state.type !== "none" ? state.selected : 0;
|
||||
|
||||
setState({
|
||||
type: "channel",
|
||||
matches,
|
||||
selected: Math.min(currentPosition, matches.length - 1),
|
||||
within: false,
|
||||
});
|
||||
setState({
|
||||
type: "channel",
|
||||
matches,
|
||||
selected: Math.min(currentPosition, matches.length - 1),
|
||||
within: false,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (state.type !== "none") {
|
||||
setState({ type: "none" });
|
||||
}
|
||||
}
|
||||
if (state.type !== "none") {
|
||||
setState({ type: "none" });
|
||||
}
|
||||
}
|
||||
|
||||
function selectCurrent(el: HTMLTextAreaElement) {
|
||||
if (state.type !== "none") {
|
||||
let result = findSearchString(el);
|
||||
if (result) {
|
||||
let [_type, search, index] = result;
|
||||
function selectCurrent(el: HTMLTextAreaElement) {
|
||||
if (state.type !== "none") {
|
||||
let result = findSearchString(el);
|
||||
if (result) {
|
||||
let [_type, search, index] = result;
|
||||
|
||||
let content = el.value.split("");
|
||||
if (state.type === "emoji") {
|
||||
content.splice(
|
||||
index,
|
||||
search.length,
|
||||
state.matches[state.selected],
|
||||
": ",
|
||||
);
|
||||
} else if (state.type === "user") {
|
||||
content.splice(
|
||||
index - 1,
|
||||
search.length + 1,
|
||||
"<@",
|
||||
state.matches[state.selected]._id,
|
||||
"> ",
|
||||
);
|
||||
} else {
|
||||
content.splice(
|
||||
index - 1,
|
||||
search.length + 1,
|
||||
"<#",
|
||||
state.matches[state.selected]._id,
|
||||
"> ",
|
||||
);
|
||||
}
|
||||
let content = el.value.split("");
|
||||
if (state.type === "emoji") {
|
||||
content.splice(
|
||||
index,
|
||||
search.length,
|
||||
state.matches[state.selected],
|
||||
": ",
|
||||
);
|
||||
} else if (state.type === "user") {
|
||||
content.splice(
|
||||
index - 1,
|
||||
search.length + 1,
|
||||
"<@",
|
||||
state.matches[state.selected]._id,
|
||||
"> ",
|
||||
);
|
||||
} else {
|
||||
content.splice(
|
||||
index - 1,
|
||||
search.length + 1,
|
||||
"<#",
|
||||
state.matches[state.selected]._id,
|
||||
"> ",
|
||||
);
|
||||
}
|
||||
|
||||
setValue(content.join(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
setValue(content.join(""));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onClick(ev: JSX.TargetedMouseEvent<HTMLButtonElement>) {
|
||||
ev.preventDefault();
|
||||
selectCurrent(document.querySelector("#message")!);
|
||||
}
|
||||
function onClick(ev: JSX.TargetedMouseEvent<HTMLButtonElement>) {
|
||||
ev.preventDefault();
|
||||
selectCurrent(document.querySelector("#message")!);
|
||||
}
|
||||
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (focused && state.type !== "none") {
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
if (state.selected > 0) {
|
||||
setState({
|
||||
...state,
|
||||
selected: state.selected - 1,
|
||||
});
|
||||
}
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (focused && state.type !== "none") {
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
if (state.selected > 0) {
|
||||
setState({
|
||||
...state,
|
||||
selected: state.selected - 1,
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (state.selected < state.matches.length - 1) {
|
||||
setState({
|
||||
...state,
|
||||
selected: state.selected + 1,
|
||||
});
|
||||
}
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
if (state.selected < state.matches.length - 1) {
|
||||
setState({
|
||||
...state,
|
||||
selected: state.selected + 1,
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
selectCurrent(e.currentTarget as HTMLTextAreaElement);
|
||||
if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
selectCurrent(e.currentTarget as HTMLTextAreaElement);
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function onKeyUp(e: KeyboardEvent) {
|
||||
if (e.currentTarget !== null) {
|
||||
// @ts-expect-error
|
||||
onChange(e);
|
||||
}
|
||||
}
|
||||
function onKeyUp(e: KeyboardEvent) {
|
||||
if (e.currentTarget !== null) {
|
||||
// @ts-expect-error
|
||||
onChange(e);
|
||||
}
|
||||
}
|
||||
|
||||
function onFocus(ev: JSX.TargetedFocusEvent<HTMLTextAreaElement>) {
|
||||
setFocused(true);
|
||||
onChange(ev);
|
||||
}
|
||||
function onFocus(ev: JSX.TargetedFocusEvent<HTMLTextAreaElement>) {
|
||||
setFocused(true);
|
||||
onChange(ev);
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
if (state.type !== "none" && state.within) return;
|
||||
setFocused(false);
|
||||
}
|
||||
function onBlur() {
|
||||
if (state.type !== "none" && state.within) return;
|
||||
setFocused(false);
|
||||
}
|
||||
|
||||
return {
|
||||
state: focused ? state : { type: "none" },
|
||||
setState,
|
||||
return {
|
||||
state: focused ? state : { type: "none" },
|
||||
setState,
|
||||
|
||||
onClick,
|
||||
onChange,
|
||||
onKeyUp,
|
||||
onKeyDown,
|
||||
onFocus,
|
||||
onBlur,
|
||||
};
|
||||
onClick,
|
||||
onChange,
|
||||
onKeyUp,
|
||||
onKeyDown,
|
||||
onFocus,
|
||||
onBlur,
|
||||
};
|
||||
}
|
||||
|
||||
const Base = styled.div<{ detached?: boolean }>`
|
||||
position: relative;
|
||||
position: relative;
|
||||
|
||||
> div {
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
background: var(--primary-header);
|
||||
}
|
||||
> div {
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
background: var(--primary-header);
|
||||
}
|
||||
|
||||
button {
|
||||
gap: 8px;
|
||||
margin: 4px;
|
||||
padding: 6px;
|
||||
border: none;
|
||||
display: flex;
|
||||
font-size: 1em;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
background: transparent;
|
||||
color: var(--foreground);
|
||||
width: calc(100% - 12px);
|
||||
button {
|
||||
gap: 8px;
|
||||
margin: 4px;
|
||||
padding: 6px;
|
||||
border: none;
|
||||
display: flex;
|
||||
font-size: 1em;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
background: transparent;
|
||||
color: var(--foreground);
|
||||
width: calc(100% - 12px);
|
||||
|
||||
span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
span {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: var(--primary-background);
|
||||
}
|
||||
}
|
||||
&.active {
|
||||
background: var(--primary-background);
|
||||
}
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.detached &&
|
||||
css`
|
||||
bottom: 8px;
|
||||
${(props) =>
|
||||
props.detached &&
|
||||
css`
|
||||
bottom: 8px;
|
||||
|
||||
> div {
|
||||
border-radius: 4px;
|
||||
}
|
||||
`}
|
||||
> div {
|
||||
border-radius: 4px;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
export default function AutoComplete({
|
||||
detached,
|
||||
state,
|
||||
setState,
|
||||
onClick,
|
||||
detached,
|
||||
state,
|
||||
setState,
|
||||
onClick,
|
||||
}: Pick<AutoCompleteProps, "detached" | "state" | "setState" | "onClick">) {
|
||||
return (
|
||||
<Base detached={detached}>
|
||||
<div>
|
||||
{state.type === "emoji" &&
|
||||
state.matches.map((match, i) => (
|
||||
<button
|
||||
className={i === state.selected ? "active" : ""}
|
||||
onMouseEnter={() =>
|
||||
(i !== state.selected || !state.within) &&
|
||||
setState({
|
||||
...state,
|
||||
selected: i,
|
||||
within: true,
|
||||
})
|
||||
}
|
||||
onMouseLeave={() =>
|
||||
state.within &&
|
||||
setState({
|
||||
...state,
|
||||
within: false,
|
||||
})
|
||||
}
|
||||
onClick={onClick}>
|
||||
<Emoji
|
||||
emoji={
|
||||
(emojiDictionary as Record<string, string>)[
|
||||
match
|
||||
]
|
||||
}
|
||||
size={20}
|
||||
/>
|
||||
:{match}:
|
||||
</button>
|
||||
))}
|
||||
{state.type === "user" &&
|
||||
state.matches.map((match, i) => (
|
||||
<button
|
||||
className={i === state.selected ? "active" : ""}
|
||||
onMouseEnter={() =>
|
||||
(i !== state.selected || !state.within) &&
|
||||
setState({
|
||||
...state,
|
||||
selected: i,
|
||||
within: true,
|
||||
})
|
||||
}
|
||||
onMouseLeave={() =>
|
||||
state.within &&
|
||||
setState({
|
||||
...state,
|
||||
within: false,
|
||||
})
|
||||
}
|
||||
onClick={onClick}>
|
||||
<UserIcon size={24} target={match} status={true} />
|
||||
{match.username}
|
||||
</button>
|
||||
))}
|
||||
{state.type === "channel" &&
|
||||
state.matches.map((match, i) => (
|
||||
<button
|
||||
className={i === state.selected ? "active" : ""}
|
||||
onMouseEnter={() =>
|
||||
(i !== state.selected || !state.within) &&
|
||||
setState({
|
||||
...state,
|
||||
selected: i,
|
||||
within: true,
|
||||
})
|
||||
}
|
||||
onMouseLeave={() =>
|
||||
state.within &&
|
||||
setState({
|
||||
...state,
|
||||
within: false,
|
||||
})
|
||||
}
|
||||
onClick={onClick}>
|
||||
<ChannelIcon size={24} target={match} />
|
||||
{match.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Base>
|
||||
);
|
||||
return (
|
||||
<Base detached={detached}>
|
||||
<div>
|
||||
{state.type === "emoji" &&
|
||||
state.matches.map((match, i) => (
|
||||
<button
|
||||
className={i === state.selected ? "active" : ""}
|
||||
onMouseEnter={() =>
|
||||
(i !== state.selected || !state.within) &&
|
||||
setState({
|
||||
...state,
|
||||
selected: i,
|
||||
within: true,
|
||||
})
|
||||
}
|
||||
onMouseLeave={() =>
|
||||
state.within &&
|
||||
setState({
|
||||
...state,
|
||||
within: false,
|
||||
})
|
||||
}
|
||||
onClick={onClick}>
|
||||
<Emoji
|
||||
emoji={
|
||||
(emojiDictionary as Record<string, string>)[
|
||||
match
|
||||
]
|
||||
}
|
||||
size={20}
|
||||
/>
|
||||
:{match}:
|
||||
</button>
|
||||
))}
|
||||
{state.type === "user" &&
|
||||
state.matches.map((match, i) => (
|
||||
<button
|
||||
className={i === state.selected ? "active" : ""}
|
||||
onMouseEnter={() =>
|
||||
(i !== state.selected || !state.within) &&
|
||||
setState({
|
||||
...state,
|
||||
selected: i,
|
||||
within: true,
|
||||
})
|
||||
}
|
||||
onMouseLeave={() =>
|
||||
state.within &&
|
||||
setState({
|
||||
...state,
|
||||
within: false,
|
||||
})
|
||||
}
|
||||
onClick={onClick}>
|
||||
<UserIcon size={24} target={match} status={true} />
|
||||
{match.username}
|
||||
</button>
|
||||
))}
|
||||
{state.type === "channel" &&
|
||||
state.matches.map((match, i) => (
|
||||
<button
|
||||
className={i === state.selected ? "active" : ""}
|
||||
onMouseEnter={() =>
|
||||
(i !== state.selected || !state.within) &&
|
||||
setState({
|
||||
...state,
|
||||
selected: i,
|
||||
within: true,
|
||||
})
|
||||
}
|
||||
onMouseLeave={() =>
|
||||
state.within &&
|
||||
setState({
|
||||
...state,
|
||||
within: false,
|
||||
})
|
||||
}
|
||||
onClick={onClick}>
|
||||
<ChannelIcon size={24} target={match} />
|
||||
{match.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,57 +9,57 @@ import { ImageIconBase, IconBaseProps } from "./IconBase";
|
||||
import fallback from "./assets/group.png";
|
||||
|
||||
interface Props
|
||||
extends IconBaseProps<
|
||||
Channels.GroupChannel | Channels.TextChannel | Channels.VoiceChannel
|
||||
> {
|
||||
isServerChannel?: boolean;
|
||||
extends IconBaseProps<
|
||||
Channels.GroupChannel | Channels.TextChannel | Channels.VoiceChannel
|
||||
> {
|
||||
isServerChannel?: boolean;
|
||||
}
|
||||
|
||||
export default function ChannelIcon(
|
||||
props: Props & Omit<JSX.HTMLAttributes<HTMLImageElement>, keyof Props>,
|
||||
props: Props & Omit<JSX.HTMLAttributes<HTMLImageElement>, keyof Props>,
|
||||
) {
|
||||
const client = useContext(AppContext);
|
||||
const client = useContext(AppContext);
|
||||
|
||||
const {
|
||||
size,
|
||||
target,
|
||||
attachment,
|
||||
isServerChannel: server,
|
||||
animate,
|
||||
children,
|
||||
as,
|
||||
...imgProps
|
||||
} = props;
|
||||
const iconURL = client.generateFileURL(
|
||||
target?.icon ?? attachment,
|
||||
{ max_side: 256 },
|
||||
animate,
|
||||
);
|
||||
const isServerChannel =
|
||||
server ||
|
||||
(target &&
|
||||
(target.channel_type === "TextChannel" ||
|
||||
target.channel_type === "VoiceChannel"));
|
||||
const {
|
||||
size,
|
||||
target,
|
||||
attachment,
|
||||
isServerChannel: server,
|
||||
animate,
|
||||
children,
|
||||
as,
|
||||
...imgProps
|
||||
} = props;
|
||||
const iconURL = client.generateFileURL(
|
||||
target?.icon ?? attachment,
|
||||
{ max_side: 256 },
|
||||
animate,
|
||||
);
|
||||
const isServerChannel =
|
||||
server ||
|
||||
(target &&
|
||||
(target.channel_type === "TextChannel" ||
|
||||
target.channel_type === "VoiceChannel"));
|
||||
|
||||
if (typeof iconURL === "undefined") {
|
||||
if (isServerChannel) {
|
||||
if (target?.channel_type === "VoiceChannel") {
|
||||
return <VolumeFull size={size} />;
|
||||
} else {
|
||||
return <Hash size={size} />;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (typeof iconURL === "undefined") {
|
||||
if (isServerChannel) {
|
||||
if (target?.channel_type === "VoiceChannel") {
|
||||
return <VolumeFull size={size} />;
|
||||
} else {
|
||||
return <Hash size={size} />;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
// ! fixme: replace fallback with <picture /> + <source />
|
||||
<ImageIconBase
|
||||
{...imgProps}
|
||||
width={size}
|
||||
height={size}
|
||||
aria-hidden="true"
|
||||
square={isServerChannel}
|
||||
src={iconURL ?? fallback}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
// ! fixme: replace fallback with <picture /> + <source />
|
||||
<ImageIconBase
|
||||
{...imgProps}
|
||||
width={size}
|
||||
height={size}
|
||||
aria-hidden="true"
|
||||
square={isServerChannel}
|
||||
src={iconURL ?? fallback}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,52 +8,52 @@ import Details from "../ui/Details";
|
||||
import { Children } from "../../types/Preact";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
defaultValue: boolean;
|
||||
id: string;
|
||||
defaultValue: boolean;
|
||||
|
||||
sticky?: boolean;
|
||||
large?: boolean;
|
||||
sticky?: boolean;
|
||||
large?: boolean;
|
||||
|
||||
summary: Children;
|
||||
children: Children;
|
||||
summary: Children;
|
||||
children: Children;
|
||||
}
|
||||
|
||||
export default function CollapsibleSection({
|
||||
id,
|
||||
defaultValue,
|
||||
summary,
|
||||
children,
|
||||
...detailsProps
|
||||
id,
|
||||
defaultValue,
|
||||
summary,
|
||||
children,
|
||||
...detailsProps
|
||||
}: Props) {
|
||||
const state: State = store.getState();
|
||||
const state: State = store.getState();
|
||||
|
||||
function setState(state: boolean) {
|
||||
if (state === defaultValue) {
|
||||
store.dispatch({
|
||||
type: "SECTION_TOGGLE_UNSET",
|
||||
id,
|
||||
} as Action);
|
||||
} else {
|
||||
store.dispatch({
|
||||
type: "SECTION_TOGGLE_SET",
|
||||
id,
|
||||
state,
|
||||
} as Action);
|
||||
}
|
||||
}
|
||||
function setState(state: boolean) {
|
||||
if (state === defaultValue) {
|
||||
store.dispatch({
|
||||
type: "SECTION_TOGGLE_UNSET",
|
||||
id,
|
||||
} as Action);
|
||||
} else {
|
||||
store.dispatch({
|
||||
type: "SECTION_TOGGLE_SET",
|
||||
id,
|
||||
state,
|
||||
} as Action);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Details
|
||||
open={state.sectionToggle[id] ?? defaultValue}
|
||||
onToggle={(e) => setState(e.currentTarget.open)}
|
||||
{...detailsProps}>
|
||||
<summary>
|
||||
<div class="padding">
|
||||
<ChevronDown size={20} />
|
||||
{summary}
|
||||
</div>
|
||||
</summary>
|
||||
{children}
|
||||
</Details>
|
||||
);
|
||||
return (
|
||||
<Details
|
||||
open={state.sectionToggle[id] ?? defaultValue}
|
||||
onToggle={(e) => setState(e.currentTarget.open)}
|
||||
{...detailsProps}>
|
||||
<summary>
|
||||
<div class="padding">
|
||||
<ChevronDown size={20} />
|
||||
{summary}
|
||||
</div>
|
||||
</summary>
|
||||
{children}
|
||||
</Details>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,29 +4,29 @@ var EMOJI_PACK = "mutant";
|
||||
const REVISION = 3;
|
||||
|
||||
export function setEmojiPack(pack: EmojiPacks) {
|
||||
EMOJI_PACK = pack;
|
||||
EMOJI_PACK = pack;
|
||||
}
|
||||
|
||||
// Originally taken from Twemoji source code,
|
||||
// re-written by bree to be more readable.
|
||||
function codePoints(rune: string) {
|
||||
const pairs = [];
|
||||
let low = 0;
|
||||
let i = 0;
|
||||
const pairs = [];
|
||||
let low = 0;
|
||||
let i = 0;
|
||||
|
||||
while (i < rune.length) {
|
||||
const charCode = rune.charCodeAt(i++);
|
||||
if (low) {
|
||||
pairs.push(0x10000 + ((low - 0xd800) << 10) + (charCode - 0xdc00));
|
||||
low = 0;
|
||||
} else if (0xd800 <= charCode && charCode <= 0xdbff) {
|
||||
low = charCode;
|
||||
} else {
|
||||
pairs.push(charCode);
|
||||
}
|
||||
}
|
||||
while (i < rune.length) {
|
||||
const charCode = rune.charCodeAt(i++);
|
||||
if (low) {
|
||||
pairs.push(0x10000 + ((low - 0xd800) << 10) + (charCode - 0xdc00));
|
||||
low = 0;
|
||||
} else if (0xd800 <= charCode && charCode <= 0xdbff) {
|
||||
low = charCode;
|
||||
} else {
|
||||
pairs.push(charCode);
|
||||
}
|
||||
}
|
||||
|
||||
return pairs;
|
||||
return pairs;
|
||||
}
|
||||
|
||||
// Taken from Twemoji source code.
|
||||
@@ -35,38 +35,38 @@ function codePoints(rune: string) {
|
||||
const UFE0Fg = /\uFE0F/g;
|
||||
const U200D = String.fromCharCode(0x200d);
|
||||
function toCodePoint(rune: string) {
|
||||
return codePoints(rune.indexOf(U200D) < 0 ? rune.replace(UFE0Fg, "") : rune)
|
||||
.map((val) => val.toString(16))
|
||||
.join("-");
|
||||
return codePoints(rune.indexOf(U200D) < 0 ? rune.replace(UFE0Fg, "") : rune)
|
||||
.map((val) => val.toString(16))
|
||||
.join("-");
|
||||
}
|
||||
|
||||
function parseEmoji(emoji: string) {
|
||||
let codepoint = toCodePoint(emoji);
|
||||
return `https://static.revolt.chat/emoji/${EMOJI_PACK}/${codepoint}.svg?rev=${REVISION}`;
|
||||
let codepoint = toCodePoint(emoji);
|
||||
return `https://static.revolt.chat/emoji/${EMOJI_PACK}/${codepoint}.svg?rev=${REVISION}`;
|
||||
}
|
||||
|
||||
export default function Emoji({
|
||||
emoji,
|
||||
size,
|
||||
emoji,
|
||||
size,
|
||||
}: {
|
||||
emoji: string;
|
||||
size?: number;
|
||||
emoji: string;
|
||||
size?: number;
|
||||
}) {
|
||||
return (
|
||||
<img
|
||||
alt={emoji}
|
||||
className="emoji"
|
||||
draggable={false}
|
||||
src={parseEmoji(emoji)}
|
||||
style={
|
||||
size ? { width: `${size}px`, height: `${size}px` } : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<img
|
||||
alt={emoji}
|
||||
className="emoji"
|
||||
draggable={false}
|
||||
src={parseEmoji(emoji)}
|
||||
style={
|
||||
size ? { width: `${size}px`, height: `${size}px` } : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function generateEmoji(emoji: string) {
|
||||
return `<img class="emoji" draggable="false" alt="${emoji}" src="${parseEmoji(
|
||||
emoji,
|
||||
)}" />`;
|
||||
return `<img class="emoji" draggable="false" alt="${emoji}" src="${parseEmoji(
|
||||
emoji,
|
||||
)}" />`;
|
||||
}
|
||||
|
||||
@@ -2,40 +2,40 @@ import { Attachment } from "revolt.js/dist/api/objects";
|
||||
import styled, { css } from "styled-components";
|
||||
|
||||
export interface IconBaseProps<T> {
|
||||
target?: T;
|
||||
attachment?: Attachment;
|
||||
target?: T;
|
||||
attachment?: Attachment;
|
||||
|
||||
size: number;
|
||||
animate?: boolean;
|
||||
size: number;
|
||||
animate?: boolean;
|
||||
}
|
||||
|
||||
interface IconModifiers {
|
||||
square?: boolean;
|
||||
square?: boolean;
|
||||
}
|
||||
|
||||
export default styled.svg<IconModifiers>`
|
||||
flex-shrink: 0;
|
||||
flex-shrink: 0;
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
|
||||
${(props) =>
|
||||
!props.square &&
|
||||
css`
|
||||
border-radius: 50%;
|
||||
`}
|
||||
}
|
||||
${(props) =>
|
||||
!props.square &&
|
||||
css`
|
||||
border-radius: 50%;
|
||||
`}
|
||||
}
|
||||
`;
|
||||
|
||||
export const ImageIconBase = styled.img<IconModifiers>`
|
||||
flex-shrink: 0;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
object-fit: cover;
|
||||
|
||||
${(props) =>
|
||||
!props.square &&
|
||||
css`
|
||||
border-radius: 50%;
|
||||
`}
|
||||
${(props) =>
|
||||
!props.square &&
|
||||
css`
|
||||
border-radius: 50%;
|
||||
`}
|
||||
`;
|
||||
|
||||
@@ -6,33 +6,33 @@ import { Language, Languages } from "../../context/Locale";
|
||||
import ComboBox from "../ui/ComboBox";
|
||||
|
||||
type Props = {
|
||||
locale: string;
|
||||
locale: string;
|
||||
};
|
||||
|
||||
export function LocaleSelector(props: Props) {
|
||||
return (
|
||||
<ComboBox
|
||||
value={props.locale}
|
||||
onChange={(e) =>
|
||||
dispatch({
|
||||
type: "SET_LOCALE",
|
||||
locale: e.currentTarget.value as Language,
|
||||
})
|
||||
}>
|
||||
{Object.keys(Languages).map((x) => {
|
||||
const l = Languages[x as keyof typeof Languages];
|
||||
return (
|
||||
<option value={x}>
|
||||
{l.emoji} {l.display}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</ComboBox>
|
||||
);
|
||||
return (
|
||||
<ComboBox
|
||||
value={props.locale}
|
||||
onChange={(e) =>
|
||||
dispatch({
|
||||
type: "SET_LOCALE",
|
||||
locale: e.currentTarget.value as Language,
|
||||
})
|
||||
}>
|
||||
{Object.keys(Languages).map((x) => {
|
||||
const l = Languages[x as keyof typeof Languages];
|
||||
return (
|
||||
<option value={x}>
|
||||
{l.emoji} {l.display}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</ComboBox>
|
||||
);
|
||||
}
|
||||
|
||||
export default connectState(LocaleSelector, (state) => {
|
||||
return {
|
||||
locale: state.locale,
|
||||
};
|
||||
return {
|
||||
locale: state.locale,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -10,40 +10,40 @@ import Header from "../ui/Header";
|
||||
import IconButton from "../ui/IconButton";
|
||||
|
||||
interface Props {
|
||||
server: Server;
|
||||
ctx: HookContext;
|
||||
server: Server;
|
||||
ctx: HookContext;
|
||||
}
|
||||
|
||||
const ServerName = styled.div`
|
||||
flex-grow: 1;
|
||||
flex-grow: 1;
|
||||
`;
|
||||
|
||||
export default function ServerHeader({ server, ctx }: Props) {
|
||||
const permissions = useServerPermission(server._id, ctx);
|
||||
const bannerURL = ctx.client.servers.getBannerURL(
|
||||
server._id,
|
||||
{ width: 480 },
|
||||
true,
|
||||
);
|
||||
const permissions = useServerPermission(server._id, ctx);
|
||||
const bannerURL = ctx.client.servers.getBannerURL(
|
||||
server._id,
|
||||
{ width: 480 },
|
||||
true,
|
||||
);
|
||||
|
||||
return (
|
||||
<Header
|
||||
borders
|
||||
placement="secondary"
|
||||
background={typeof bannerURL !== "undefined"}
|
||||
style={{
|
||||
background: bannerURL ? `url('${bannerURL}')` : undefined,
|
||||
}}>
|
||||
<ServerName>{server.name}</ServerName>
|
||||
{(permissions & ServerPermission.ManageServer) > 0 && (
|
||||
<div className="actions">
|
||||
<Link to={`/server/${server._id}/settings`}>
|
||||
<IconButton>
|
||||
<Cog size={24} />
|
||||
</IconButton>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</Header>
|
||||
);
|
||||
return (
|
||||
<Header
|
||||
borders
|
||||
placement="secondary"
|
||||
background={typeof bannerURL !== "undefined"}
|
||||
style={{
|
||||
background: bannerURL ? `url('${bannerURL}')` : undefined,
|
||||
}}>
|
||||
<ServerName>{server.name}</ServerName>
|
||||
{(permissions & ServerPermission.ManageServer) > 0 && (
|
||||
<div className="actions">
|
||||
<Link to={`/server/${server._id}/settings`}>
|
||||
<IconButton>
|
||||
<Cog size={24} />
|
||||
</IconButton>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</Header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,61 +8,61 @@ import { AppContext } from "../../context/revoltjs/RevoltClient";
|
||||
import { IconBaseProps, ImageIconBase } from "./IconBase";
|
||||
|
||||
interface Props extends IconBaseProps<Server> {
|
||||
server_name?: string;
|
||||
server_name?: string;
|
||||
}
|
||||
|
||||
const ServerText = styled.div`
|
||||
display: grid;
|
||||
padding: 0.2em;
|
||||
overflow: hidden;
|
||||
border-radius: 50%;
|
||||
place-items: center;
|
||||
color: var(--foreground);
|
||||
background: var(--primary-background);
|
||||
display: grid;
|
||||
padding: 0.2em;
|
||||
overflow: hidden;
|
||||
border-radius: 50%;
|
||||
place-items: center;
|
||||
color: var(--foreground);
|
||||
background: var(--primary-background);
|
||||
`;
|
||||
|
||||
const fallback = "/assets/group.png";
|
||||
export default function ServerIcon(
|
||||
props: Props & Omit<JSX.HTMLAttributes<HTMLImageElement>, keyof Props>,
|
||||
props: Props & Omit<JSX.HTMLAttributes<HTMLImageElement>, keyof Props>,
|
||||
) {
|
||||
const client = useContext(AppContext);
|
||||
const client = useContext(AppContext);
|
||||
|
||||
const {
|
||||
target,
|
||||
attachment,
|
||||
size,
|
||||
animate,
|
||||
server_name,
|
||||
children,
|
||||
as,
|
||||
...imgProps
|
||||
} = props;
|
||||
const iconURL = client.generateFileURL(
|
||||
target?.icon ?? attachment,
|
||||
{ max_side: 256 },
|
||||
animate,
|
||||
);
|
||||
const {
|
||||
target,
|
||||
attachment,
|
||||
size,
|
||||
animate,
|
||||
server_name,
|
||||
children,
|
||||
as,
|
||||
...imgProps
|
||||
} = props;
|
||||
const iconURL = client.generateFileURL(
|
||||
target?.icon ?? attachment,
|
||||
{ max_side: 256 },
|
||||
animate,
|
||||
);
|
||||
|
||||
if (typeof iconURL === "undefined") {
|
||||
const name = target?.name ?? server_name ?? "";
|
||||
if (typeof iconURL === "undefined") {
|
||||
const name = target?.name ?? server_name ?? "";
|
||||
|
||||
return (
|
||||
<ServerText style={{ width: size, height: size }}>
|
||||
{name
|
||||
.split(" ")
|
||||
.map((x) => x[0])
|
||||
.filter((x) => typeof x !== "undefined")}
|
||||
</ServerText>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ServerText style={{ width: size, height: size }}>
|
||||
{name
|
||||
.split(" ")
|
||||
.map((x) => x[0])
|
||||
.filter((x) => typeof x !== "undefined")}
|
||||
</ServerText>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ImageIconBase
|
||||
{...imgProps}
|
||||
width={size}
|
||||
height={size}
|
||||
aria-hidden="true"
|
||||
src={iconURL}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<ImageIconBase
|
||||
{...imgProps}
|
||||
width={size}
|
||||
height={size}
|
||||
aria-hidden="true"
|
||||
src={iconURL}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,55 +6,55 @@ import { Text } from "preact-i18n";
|
||||
import { Children } from "../../types/Preact";
|
||||
|
||||
type Props = Omit<TippyProps, "children"> & {
|
||||
children: Children;
|
||||
content: Children;
|
||||
children: Children;
|
||||
content: Children;
|
||||
};
|
||||
|
||||
export default function Tooltip(props: Props) {
|
||||
const { children, content, ...tippyProps } = props;
|
||||
const { children, content, ...tippyProps } = props;
|
||||
|
||||
return (
|
||||
<Tippy content={content} {...tippyProps}>
|
||||
{/*
|
||||
return (
|
||||
<Tippy content={content} {...tippyProps}>
|
||||
{/*
|
||||
// @ts-expect-error */}
|
||||
<div>{children}</div>
|
||||
</Tippy>
|
||||
);
|
||||
<div>{children}</div>
|
||||
</Tippy>
|
||||
);
|
||||
}
|
||||
|
||||
const PermissionTooltipBase = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
|
||||
span {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: var(--secondary-foreground);
|
||||
font-size: 11px;
|
||||
}
|
||||
span {
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
color: var(--secondary-foreground);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: var(--monoscape-font);
|
||||
}
|
||||
code {
|
||||
font-family: var(--monoscape-font);
|
||||
}
|
||||
`;
|
||||
|
||||
export function PermissionTooltip(
|
||||
props: Omit<Props, "content"> & { permission: string },
|
||||
props: Omit<Props, "content"> & { permission: string },
|
||||
) {
|
||||
const { permission, ...tooltipProps } = props;
|
||||
const { permission, ...tooltipProps } = props;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
content={
|
||||
<PermissionTooltipBase>
|
||||
<span>
|
||||
<Text id="app.permissions.required" />
|
||||
</span>
|
||||
<code>{permission}</code>
|
||||
</PermissionTooltipBase>
|
||||
}
|
||||
{...tooltipProps}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Tooltip
|
||||
content={
|
||||
<PermissionTooltipBase>
|
||||
<span>
|
||||
<Text id="app.permissions.required" />
|
||||
</span>
|
||||
<code>{permission}</code>
|
||||
</PermissionTooltipBase>
|
||||
}
|
||||
{...tooltipProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -14,18 +14,18 @@ var pendingUpdate = false;
|
||||
internalSubscribe("PWA", "update", () => (pendingUpdate = true));
|
||||
|
||||
export default function UpdateIndicator() {
|
||||
const [pending, setPending] = useState(pendingUpdate);
|
||||
const [pending, setPending] = useState(pendingUpdate);
|
||||
|
||||
useEffect(() => {
|
||||
return internalSubscribe("PWA", "update", () => setPending(true));
|
||||
});
|
||||
useEffect(() => {
|
||||
return internalSubscribe("PWA", "update", () => setPending(true));
|
||||
});
|
||||
|
||||
if (!pending) return null;
|
||||
const theme = useContext(ThemeContext);
|
||||
if (!pending) return null;
|
||||
const theme = useContext(ThemeContext);
|
||||
|
||||
return (
|
||||
<IconButton onClick={() => updateSW(true)}>
|
||||
<Download size={22} color={theme.success} />
|
||||
</IconButton>
|
||||
);
|
||||
return (
|
||||
<IconButton onClick={() => updateSW(true)}>
|
||||
<Download size={22} color={theme.success} />
|
||||
</IconButton>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,118 +16,118 @@ import Markdown from "../../markdown/Markdown";
|
||||
import UserIcon from "../user/UserIcon";
|
||||
import { Username } from "../user/UserShort";
|
||||
import MessageBase, {
|
||||
MessageContent,
|
||||
MessageDetail,
|
||||
MessageInfo,
|
||||
MessageContent,
|
||||
MessageDetail,
|
||||
MessageInfo,
|
||||
} from "./MessageBase";
|
||||
import Attachment from "./attachments/Attachment";
|
||||
import { MessageReply } from "./attachments/MessageReply";
|
||||
import Embed from "./embed/Embed";
|
||||
|
||||
interface Props {
|
||||
attachContext?: boolean;
|
||||
queued?: QueuedMessage;
|
||||
message: MessageObject;
|
||||
contrast?: boolean;
|
||||
content?: Children;
|
||||
head?: boolean;
|
||||
attachContext?: boolean;
|
||||
queued?: QueuedMessage;
|
||||
message: MessageObject;
|
||||
contrast?: boolean;
|
||||
content?: Children;
|
||||
head?: boolean;
|
||||
}
|
||||
|
||||
function Message({
|
||||
attachContext,
|
||||
message,
|
||||
contrast,
|
||||
content: replacement,
|
||||
head: preferHead,
|
||||
queued,
|
||||
attachContext,
|
||||
message,
|
||||
contrast,
|
||||
content: replacement,
|
||||
head: preferHead,
|
||||
queued,
|
||||
}: Props) {
|
||||
// TODO: Can improve re-renders here by providing a list
|
||||
// TODO: of dependencies. We only need to update on u/avatar.
|
||||
const user = useUser(message.author);
|
||||
const client = useContext(AppContext);
|
||||
const { openScreen } = useIntermediate();
|
||||
// TODO: Can improve re-renders here by providing a list
|
||||
// TODO: of dependencies. We only need to update on u/avatar.
|
||||
const user = useUser(message.author);
|
||||
const client = useContext(AppContext);
|
||||
const { openScreen } = useIntermediate();
|
||||
|
||||
const content = message.content as string;
|
||||
const head = preferHead || (message.replies && message.replies.length > 0);
|
||||
const content = message.content as string;
|
||||
const head = preferHead || (message.replies && message.replies.length > 0);
|
||||
|
||||
// ! FIXME: tell fatal to make this type generic
|
||||
// bree: Fatal please...
|
||||
const userContext = attachContext
|
||||
? (attachContextMenu("Menu", {
|
||||
user: message.author,
|
||||
contextualChannel: message.channel,
|
||||
}) as any)
|
||||
: undefined;
|
||||
// ! FIXME: tell fatal to make this type generic
|
||||
// bree: Fatal please...
|
||||
const userContext = attachContext
|
||||
? (attachContextMenu("Menu", {
|
||||
user: message.author,
|
||||
contextualChannel: message.channel,
|
||||
}) as any)
|
||||
: undefined;
|
||||
|
||||
const openProfile = () =>
|
||||
openScreen({ id: "profile", user_id: message.author });
|
||||
const openProfile = () =>
|
||||
openScreen({ id: "profile", user_id: message.author });
|
||||
|
||||
return (
|
||||
<div id={message._id}>
|
||||
{message.replies?.map((message_id, index) => (
|
||||
<MessageReply
|
||||
index={index}
|
||||
id={message_id}
|
||||
channel={message.channel}
|
||||
/>
|
||||
))}
|
||||
<MessageBase
|
||||
head={head && !(message.replies && message.replies.length > 0)}
|
||||
contrast={contrast}
|
||||
sending={typeof queued !== "undefined"}
|
||||
mention={message.mentions?.includes(client.user!._id)}
|
||||
failed={typeof queued?.error !== "undefined"}
|
||||
onContextMenu={
|
||||
attachContext
|
||||
? attachContextMenu("Menu", {
|
||||
message,
|
||||
contextualChannel: message.channel,
|
||||
queued,
|
||||
})
|
||||
: undefined
|
||||
}>
|
||||
<MessageInfo>
|
||||
{head ? (
|
||||
<UserIcon
|
||||
target={user}
|
||||
size={36}
|
||||
onContextMenu={userContext}
|
||||
onClick={openProfile}
|
||||
/>
|
||||
) : (
|
||||
<MessageDetail message={message} position="left" />
|
||||
)}
|
||||
</MessageInfo>
|
||||
<MessageContent>
|
||||
{head && (
|
||||
<span className="detail">
|
||||
<Username
|
||||
className="author"
|
||||
user={user}
|
||||
onContextMenu={userContext}
|
||||
onClick={openProfile}
|
||||
/>
|
||||
<MessageDetail message={message} position="top" />
|
||||
</span>
|
||||
)}
|
||||
{replacement ?? <Markdown content={content} />}
|
||||
{queued?.error && (
|
||||
<Overline type="error" error={queued.error} />
|
||||
)}
|
||||
{message.attachments?.map((attachment, index) => (
|
||||
<Attachment
|
||||
key={index}
|
||||
attachment={attachment}
|
||||
hasContent={index > 0 || content.length > 0}
|
||||
/>
|
||||
))}
|
||||
{message.embeds?.map((embed, index) => (
|
||||
<Embed key={index} embed={embed} />
|
||||
))}
|
||||
</MessageContent>
|
||||
</MessageBase>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div id={message._id}>
|
||||
{message.replies?.map((message_id, index) => (
|
||||
<MessageReply
|
||||
index={index}
|
||||
id={message_id}
|
||||
channel={message.channel}
|
||||
/>
|
||||
))}
|
||||
<MessageBase
|
||||
head={head && !(message.replies && message.replies.length > 0)}
|
||||
contrast={contrast}
|
||||
sending={typeof queued !== "undefined"}
|
||||
mention={message.mentions?.includes(client.user!._id)}
|
||||
failed={typeof queued?.error !== "undefined"}
|
||||
onContextMenu={
|
||||
attachContext
|
||||
? attachContextMenu("Menu", {
|
||||
message,
|
||||
contextualChannel: message.channel,
|
||||
queued,
|
||||
})
|
||||
: undefined
|
||||
}>
|
||||
<MessageInfo>
|
||||
{head ? (
|
||||
<UserIcon
|
||||
target={user}
|
||||
size={36}
|
||||
onContextMenu={userContext}
|
||||
onClick={openProfile}
|
||||
/>
|
||||
) : (
|
||||
<MessageDetail message={message} position="left" />
|
||||
)}
|
||||
</MessageInfo>
|
||||
<MessageContent>
|
||||
{head && (
|
||||
<span className="detail">
|
||||
<Username
|
||||
className="author"
|
||||
user={user}
|
||||
onContextMenu={userContext}
|
||||
onClick={openProfile}
|
||||
/>
|
||||
<MessageDetail message={message} position="top" />
|
||||
</span>
|
||||
)}
|
||||
{replacement ?? <Markdown content={content} />}
|
||||
{queued?.error && (
|
||||
<Overline type="error" error={queued.error} />
|
||||
)}
|
||||
{message.attachments?.map((attachment, index) => (
|
||||
<Attachment
|
||||
key={index}
|
||||
attachment={attachment}
|
||||
hasContent={index > 0 || content.length > 0}
|
||||
/>
|
||||
))}
|
||||
{message.embeds?.map((embed, index) => (
|
||||
<Embed key={index} embed={embed} />
|
||||
))}
|
||||
</MessageContent>
|
||||
</MessageBase>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(Message);
|
||||
|
||||
@@ -9,204 +9,204 @@ import { MessageObject } from "../../../context/revoltjs/util";
|
||||
import Tooltip from "../Tooltip";
|
||||
|
||||
export interface BaseMessageProps {
|
||||
head?: boolean;
|
||||
failed?: boolean;
|
||||
mention?: boolean;
|
||||
blocked?: boolean;
|
||||
sending?: boolean;
|
||||
contrast?: boolean;
|
||||
head?: boolean;
|
||||
failed?: boolean;
|
||||
mention?: boolean;
|
||||
blocked?: boolean;
|
||||
sending?: boolean;
|
||||
contrast?: boolean;
|
||||
}
|
||||
|
||||
export default styled.div<BaseMessageProps>`
|
||||
display: flex;
|
||||
overflow-x: none;
|
||||
padding: 0.125rem;
|
||||
flex-direction: row;
|
||||
padding-right: 16px;
|
||||
|
||||
${(props) =>
|
||||
props.contrast &&
|
||||
css`
|
||||
padding: 0.3rem;
|
||||
border-radius: 4px;
|
||||
background: var(--hover);
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.head &&
|
||||
css`
|
||||
margin-top: 12px;
|
||||
`}
|
||||
display: flex;
|
||||
overflow-x: none;
|
||||
padding: 0.125rem;
|
||||
flex-direction: row;
|
||||
padding-right: 16px;
|
||||
|
||||
${(props) =>
|
||||
props.mention &&
|
||||
css`
|
||||
background: var(--mention);
|
||||
`}
|
||||
props.contrast &&
|
||||
css`
|
||||
padding: 0.3rem;
|
||||
border-radius: 4px;
|
||||
background: var(--hover);
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.blocked &&
|
||||
css`
|
||||
filter: blur(4px);
|
||||
transition: 0.2s ease filter;
|
||||
|
||||
&:hover {
|
||||
filter: none;
|
||||
}
|
||||
`}
|
||||
props.head &&
|
||||
css`
|
||||
margin-top: 12px;
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.sending &&
|
||||
css`
|
||||
opacity: 0.8;
|
||||
color: var(--tertiary-foreground);
|
||||
`}
|
||||
props.mention &&
|
||||
css`
|
||||
background: var(--mention);
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.failed &&
|
||||
css`
|
||||
color: var(--error);
|
||||
`}
|
||||
props.blocked &&
|
||||
css`
|
||||
filter: blur(4px);
|
||||
transition: 0.2s ease filter;
|
||||
|
||||
&:hover {
|
||||
filter: none;
|
||||
}
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.sending &&
|
||||
css`
|
||||
opacity: 0.8;
|
||||
color: var(--tertiary-foreground);
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.failed &&
|
||||
css`
|
||||
color: var(--error);
|
||||
`}
|
||||
|
||||
.detail {
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.author {
|
||||
cursor: pointer;
|
||||
font-weight: 600 !important;
|
||||
.author {
|
||||
cursor: pointer;
|
||||
font-weight: 600 !important;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
.copy {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
.copy {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--hover);
|
||||
&:hover {
|
||||
background: var(--hover);
|
||||
|
||||
time {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
time {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const MessageInfo = styled.div`
|
||||
width: 62px;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
width: 62px;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
padding-top: 2px;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
|
||||
.copyBracket {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
}
|
||||
.copyBracket {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.copyTime {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
}
|
||||
.copyTime {
|
||||
opacity: 0;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
svg {
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
svg {
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
}
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
}
|
||||
|
||||
time {
|
||||
opacity: 0;
|
||||
}
|
||||
time {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
time,
|
||||
.edited {
|
||||
margin-top: 1px;
|
||||
cursor: default;
|
||||
display: inline;
|
||||
font-size: 10px;
|
||||
color: var(--tertiary-foreground);
|
||||
}
|
||||
time,
|
||||
.edited {
|
||||
margin-top: 1px;
|
||||
cursor: default;
|
||||
display: inline;
|
||||
font-size: 10px;
|
||||
color: var(--tertiary-foreground);
|
||||
}
|
||||
|
||||
time,
|
||||
.edited > div {
|
||||
&::selection {
|
||||
background-color: transparent;
|
||||
color: var(--tertiary-foreground);
|
||||
}
|
||||
}
|
||||
time,
|
||||
.edited > div {
|
||||
&::selection {
|
||||
background-color: transparent;
|
||||
color: var(--tertiary-foreground);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const MessageContent = styled.div`
|
||||
min-width: 0;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
// overflow: hidden;
|
||||
font-size: 0.875rem;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
min-width: 0;
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
// overflow: hidden;
|
||||
font-size: 0.875rem;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
`;
|
||||
|
||||
export const DetailBase = styled.div`
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
display: inline-flex;
|
||||
color: var(--tertiary-foreground);
|
||||
gap: 4px;
|
||||
font-size: 10px;
|
||||
display: inline-flex;
|
||||
color: var(--tertiary-foreground);
|
||||
`;
|
||||
|
||||
export function MessageDetail({
|
||||
message,
|
||||
position,
|
||||
message,
|
||||
position,
|
||||
}: {
|
||||
message: MessageObject;
|
||||
position: "left" | "top";
|
||||
message: MessageObject;
|
||||
position: "left" | "top";
|
||||
}) {
|
||||
if (position === "left") {
|
||||
if (message.edited) {
|
||||
return (
|
||||
<>
|
||||
<time className="copyTime">
|
||||
<i className="copyBracket">[</i>
|
||||
{dayjs(decodeTime(message._id)).format("H:mm")}
|
||||
<i className="copyBracket">]</i>
|
||||
</time>
|
||||
<span className="edited">
|
||||
<Tooltip content={dayjs(message.edited).format("LLLL")}>
|
||||
<Text id="app.main.channel.edited" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<time>
|
||||
<i className="copyBracket">[</i>
|
||||
{dayjs(decodeTime(message._id)).format("H:mm")}
|
||||
<i className="copyBracket">]</i>
|
||||
</time>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
if (position === "left") {
|
||||
if (message.edited) {
|
||||
return (
|
||||
<>
|
||||
<time className="copyTime">
|
||||
<i className="copyBracket">[</i>
|
||||
{dayjs(decodeTime(message._id)).format("H:mm")}
|
||||
<i className="copyBracket">]</i>
|
||||
</time>
|
||||
<span className="edited">
|
||||
<Tooltip content={dayjs(message.edited).format("LLLL")}>
|
||||
<Text id="app.main.channel.edited" />
|
||||
</Tooltip>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<time>
|
||||
<i className="copyBracket">[</i>
|
||||
{dayjs(decodeTime(message._id)).format("H:mm")}
|
||||
<i className="copyBracket">]</i>
|
||||
</time>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DetailBase>
|
||||
<time>{dayjs(decodeTime(message._id)).calendar()}</time>
|
||||
{message.edited && (
|
||||
<Tooltip content={dayjs(message.edited).format("LLLL")}>
|
||||
<Text id="app.main.channel.edited" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</DetailBase>
|
||||
);
|
||||
return (
|
||||
<DetailBase>
|
||||
<time>{dayjs(decodeTime(message._id)).calendar()}</time>
|
||||
{message.edited && (
|
||||
<Tooltip content={dayjs(message.edited).format("LLLL")}>
|
||||
<Text id="app.main.channel.edited" />
|
||||
</Tooltip>
|
||||
)}
|
||||
</DetailBase>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ import { internalEmit, internalSubscribe } from "../../../lib/eventEmitter";
|
||||
import { useTranslation } from "../../../lib/i18n";
|
||||
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
|
||||
import {
|
||||
SingletonMessageRenderer,
|
||||
SMOOTH_SCROLL_ON_RECEIVE,
|
||||
SingletonMessageRenderer,
|
||||
SMOOTH_SCROLL_ON_RECEIVE,
|
||||
} from "../../../lib/renderer/Singleton";
|
||||
|
||||
import { dispatch } from "../../../redux";
|
||||
@@ -27,9 +27,9 @@ import { Reply } from "../../../redux/reducers/queue";
|
||||
import { SoundContext } from "../../../context/Settings";
|
||||
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
||||
import {
|
||||
FileUploader,
|
||||
grabFiles,
|
||||
uploadFile,
|
||||
FileUploader,
|
||||
grabFiles,
|
||||
uploadFile,
|
||||
} from "../../../context/revoltjs/FileUploads";
|
||||
import { AppContext } from "../../../context/revoltjs/RevoltClient";
|
||||
import { useChannelPermission } from "../../../context/revoltjs/hooks";
|
||||
@@ -43,454 +43,452 @@ import FilePreview from "./bars/FilePreview";
|
||||
import ReplyBar from "./bars/ReplyBar";
|
||||
|
||||
type Props = {
|
||||
channel: Channel;
|
||||
draft?: string;
|
||||
channel: Channel;
|
||||
draft?: string;
|
||||
};
|
||||
|
||||
export type UploadState =
|
||||
| { type: "none" }
|
||||
| { type: "attached"; files: File[] }
|
||||
| {
|
||||
type: "uploading";
|
||||
files: File[];
|
||||
percent: number;
|
||||
cancel: CancelTokenSource;
|
||||
}
|
||||
| { type: "sending"; files: File[] }
|
||||
| { type: "failed"; files: File[]; error: string };
|
||||
| { type: "none" }
|
||||
| { type: "attached"; files: File[] }
|
||||
| {
|
||||
type: "uploading";
|
||||
files: File[];
|
||||
percent: number;
|
||||
cancel: CancelTokenSource;
|
||||
}
|
||||
| { type: "sending"; files: File[] }
|
||||
| { type: "failed"; files: File[]; error: string };
|
||||
|
||||
const Base = styled.div`
|
||||
display: flex;
|
||||
padding: 0 12px;
|
||||
background: var(--message-box);
|
||||
display: flex;
|
||||
padding: 0 12px;
|
||||
background: var(--message-box);
|
||||
|
||||
textarea {
|
||||
font-size: 0.875rem;
|
||||
background: transparent;
|
||||
}
|
||||
textarea {
|
||||
font-size: 0.875rem;
|
||||
background: transparent;
|
||||
}
|
||||
`;
|
||||
|
||||
const Blocked = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 0;
|
||||
user-select: none;
|
||||
font-size: 0.875rem;
|
||||
color: var(--tertiary-foreground);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 0;
|
||||
user-select: none;
|
||||
font-size: 0.875rem;
|
||||
color: var(--tertiary-foreground);
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
`;
|
||||
|
||||
const Action = styled.div`
|
||||
display: grid;
|
||||
place-items: center;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
`;
|
||||
|
||||
// ! FIXME: add to app config and load from app config
|
||||
export const CAN_UPLOAD_AT_ONCE = 5;
|
||||
|
||||
function MessageBox({ channel, draft }: Props) {
|
||||
const [uploadState, setUploadState] = useState<UploadState>({
|
||||
type: "none",
|
||||
});
|
||||
const [typing, setTyping] = useState<boolean | number>(false);
|
||||
const [replies, setReplies] = useState<Reply[]>([]);
|
||||
const playSound = useContext(SoundContext);
|
||||
const { openScreen } = useIntermediate();
|
||||
const client = useContext(AppContext);
|
||||
const translate = useTranslation();
|
||||
const [uploadState, setUploadState] = useState<UploadState>({
|
||||
type: "none",
|
||||
});
|
||||
const [typing, setTyping] = useState<boolean | number>(false);
|
||||
const [replies, setReplies] = useState<Reply[]>([]);
|
||||
const playSound = useContext(SoundContext);
|
||||
const { openScreen } = useIntermediate();
|
||||
const client = useContext(AppContext);
|
||||
const translate = useTranslation();
|
||||
|
||||
const permissions = useChannelPermission(channel._id);
|
||||
if (!(permissions & ChannelPermission.SendMessage)) {
|
||||
return (
|
||||
<Base>
|
||||
<Blocked>
|
||||
<PermissionTooltip
|
||||
permission="SendMessages"
|
||||
placement="top">
|
||||
<ShieldX size={22} />
|
||||
</PermissionTooltip>
|
||||
<Text id="app.main.channel.misc.no_sending" />
|
||||
</Blocked>
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
const permissions = useChannelPermission(channel._id);
|
||||
if (!(permissions & ChannelPermission.SendMessage)) {
|
||||
return (
|
||||
<Base>
|
||||
<Blocked>
|
||||
<PermissionTooltip
|
||||
permission="SendMessages"
|
||||
placement="top">
|
||||
<ShieldX size={22} />
|
||||
</PermissionTooltip>
|
||||
<Text id="app.main.channel.misc.no_sending" />
|
||||
</Blocked>
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
function setMessage(content?: string) {
|
||||
if (content) {
|
||||
dispatch({
|
||||
type: "SET_DRAFT",
|
||||
channel: channel._id,
|
||||
content,
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: "CLEAR_DRAFT",
|
||||
channel: channel._id,
|
||||
});
|
||||
}
|
||||
}
|
||||
function setMessage(content?: string) {
|
||||
if (content) {
|
||||
dispatch({
|
||||
type: "SET_DRAFT",
|
||||
channel: channel._id,
|
||||
content,
|
||||
});
|
||||
} else {
|
||||
dispatch({
|
||||
type: "CLEAR_DRAFT",
|
||||
channel: channel._id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
function append(content: string, action: "quote" | "mention") {
|
||||
const text =
|
||||
action === "quote"
|
||||
? `${content
|
||||
.split("\n")
|
||||
.map((x) => `> ${x}`)
|
||||
.join("\n")}\n\n`
|
||||
: `${content} `;
|
||||
useEffect(() => {
|
||||
function append(content: string, action: "quote" | "mention") {
|
||||
const text =
|
||||
action === "quote"
|
||||
? `${content
|
||||
.split("\n")
|
||||
.map((x) => `> ${x}`)
|
||||
.join("\n")}\n\n`
|
||||
: `${content} `;
|
||||
|
||||
if (!draft || draft.length === 0) {
|
||||
setMessage(text);
|
||||
} else {
|
||||
setMessage(`${draft}\n${text}`);
|
||||
}
|
||||
}
|
||||
if (!draft || draft.length === 0) {
|
||||
setMessage(text);
|
||||
} else {
|
||||
setMessage(`${draft}\n${text}`);
|
||||
}
|
||||
}
|
||||
|
||||
return internalSubscribe("MessageBox", "append", append);
|
||||
}, [draft]);
|
||||
return internalSubscribe("MessageBox", "append", append);
|
||||
}, [draft]);
|
||||
|
||||
async function send() {
|
||||
if (uploadState.type === "uploading" || uploadState.type === "sending")
|
||||
return;
|
||||
async function send() {
|
||||
if (uploadState.type === "uploading" || uploadState.type === "sending")
|
||||
return;
|
||||
|
||||
const content = draft?.trim() ?? "";
|
||||
if (uploadState.type === "attached") return sendFile(content);
|
||||
if (content.length === 0) return;
|
||||
const content = draft?.trim() ?? "";
|
||||
if (uploadState.type === "attached") return sendFile(content);
|
||||
if (content.length === 0) return;
|
||||
|
||||
stopTyping();
|
||||
setMessage();
|
||||
setReplies([]);
|
||||
playSound("outbound");
|
||||
stopTyping();
|
||||
setMessage();
|
||||
setReplies([]);
|
||||
playSound("outbound");
|
||||
|
||||
const nonce = ulid();
|
||||
dispatch({
|
||||
type: "QUEUE_ADD",
|
||||
nonce,
|
||||
channel: channel._id,
|
||||
message: {
|
||||
_id: nonce,
|
||||
channel: channel._id,
|
||||
author: client.user!._id,
|
||||
const nonce = ulid();
|
||||
dispatch({
|
||||
type: "QUEUE_ADD",
|
||||
nonce,
|
||||
channel: channel._id,
|
||||
message: {
|
||||
_id: nonce,
|
||||
channel: channel._id,
|
||||
author: client.user!._id,
|
||||
|
||||
content,
|
||||
replies,
|
||||
},
|
||||
});
|
||||
content,
|
||||
replies,
|
||||
},
|
||||
});
|
||||
|
||||
defer(() =>
|
||||
SingletonMessageRenderer.jumpToBottom(
|
||||
channel._id,
|
||||
SMOOTH_SCROLL_ON_RECEIVE,
|
||||
),
|
||||
);
|
||||
defer(() =>
|
||||
SingletonMessageRenderer.jumpToBottom(
|
||||
channel._id,
|
||||
SMOOTH_SCROLL_ON_RECEIVE,
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
await client.channels.sendMessage(channel._id, {
|
||||
content,
|
||||
nonce,
|
||||
replies,
|
||||
});
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: "QUEUE_FAIL",
|
||||
error: takeError(error),
|
||||
nonce,
|
||||
});
|
||||
}
|
||||
}
|
||||
try {
|
||||
await client.channels.sendMessage(channel._id, {
|
||||
content,
|
||||
nonce,
|
||||
replies,
|
||||
});
|
||||
} catch (error) {
|
||||
dispatch({
|
||||
type: "QUEUE_FAIL",
|
||||
error: takeError(error),
|
||||
nonce,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function sendFile(content: string) {
|
||||
if (uploadState.type !== "attached") return;
|
||||
let attachments: string[] = [];
|
||||
async function sendFile(content: string) {
|
||||
if (uploadState.type !== "attached") return;
|
||||
let attachments: string[] = [];
|
||||
|
||||
const cancel = Axios.CancelToken.source();
|
||||
const files = uploadState.files;
|
||||
stopTyping();
|
||||
setUploadState({ type: "uploading", files, percent: 0, cancel });
|
||||
const cancel = Axios.CancelToken.source();
|
||||
const files = uploadState.files;
|
||||
stopTyping();
|
||||
setUploadState({ type: "uploading", files, percent: 0, cancel });
|
||||
|
||||
try {
|
||||
for (let i = 0; i < files.length && i < CAN_UPLOAD_AT_ONCE; i++) {
|
||||
const file = files[i];
|
||||
attachments.push(
|
||||
await uploadFile(
|
||||
client.configuration!.features.autumn.url,
|
||||
"attachments",
|
||||
file,
|
||||
{
|
||||
onUploadProgress: (e) =>
|
||||
setUploadState({
|
||||
type: "uploading",
|
||||
files,
|
||||
percent: Math.round(
|
||||
(i * 100 + (100 * e.loaded) / e.total) /
|
||||
Math.min(
|
||||
files.length,
|
||||
CAN_UPLOAD_AT_ONCE,
|
||||
),
|
||||
),
|
||||
cancel,
|
||||
}),
|
||||
cancelToken: cancel.token,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err?.message === "cancel") {
|
||||
setUploadState({
|
||||
type: "attached",
|
||||
files,
|
||||
});
|
||||
} else {
|
||||
setUploadState({
|
||||
type: "failed",
|
||||
files,
|
||||
error: takeError(err),
|
||||
});
|
||||
}
|
||||
try {
|
||||
for (let i = 0; i < files.length && i < CAN_UPLOAD_AT_ONCE; i++) {
|
||||
const file = files[i];
|
||||
attachments.push(
|
||||
await uploadFile(
|
||||
client.configuration!.features.autumn.url,
|
||||
"attachments",
|
||||
file,
|
||||
{
|
||||
onUploadProgress: (e) =>
|
||||
setUploadState({
|
||||
type: "uploading",
|
||||
files,
|
||||
percent: Math.round(
|
||||
(i * 100 + (100 * e.loaded) / e.total) /
|
||||
Math.min(
|
||||
files.length,
|
||||
CAN_UPLOAD_AT_ONCE,
|
||||
),
|
||||
),
|
||||
cancel,
|
||||
}),
|
||||
cancelToken: cancel.token,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err?.message === "cancel") {
|
||||
setUploadState({
|
||||
type: "attached",
|
||||
files,
|
||||
});
|
||||
} else {
|
||||
setUploadState({
|
||||
type: "failed",
|
||||
files,
|
||||
error: takeError(err),
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadState({
|
||||
type: "sending",
|
||||
files,
|
||||
});
|
||||
setUploadState({
|
||||
type: "sending",
|
||||
files,
|
||||
});
|
||||
|
||||
const nonce = ulid();
|
||||
try {
|
||||
await client.channels.sendMessage(channel._id, {
|
||||
content,
|
||||
nonce,
|
||||
replies,
|
||||
attachments,
|
||||
});
|
||||
} catch (err) {
|
||||
setUploadState({
|
||||
type: "failed",
|
||||
files,
|
||||
error: takeError(err),
|
||||
});
|
||||
const nonce = ulid();
|
||||
try {
|
||||
await client.channels.sendMessage(channel._id, {
|
||||
content,
|
||||
nonce,
|
||||
replies,
|
||||
attachments,
|
||||
});
|
||||
} catch (err) {
|
||||
setUploadState({
|
||||
type: "failed",
|
||||
files,
|
||||
error: takeError(err),
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setMessage();
|
||||
setReplies([]);
|
||||
playSound("outbound");
|
||||
setMessage();
|
||||
setReplies([]);
|
||||
playSound("outbound");
|
||||
|
||||
if (files.length > CAN_UPLOAD_AT_ONCE) {
|
||||
setUploadState({
|
||||
type: "attached",
|
||||
files: files.slice(CAN_UPLOAD_AT_ONCE),
|
||||
});
|
||||
} else {
|
||||
setUploadState({ type: "none" });
|
||||
}
|
||||
}
|
||||
if (files.length > CAN_UPLOAD_AT_ONCE) {
|
||||
setUploadState({
|
||||
type: "attached",
|
||||
files: files.slice(CAN_UPLOAD_AT_ONCE),
|
||||
});
|
||||
} else {
|
||||
setUploadState({ type: "none" });
|
||||
}
|
||||
}
|
||||
|
||||
function startTyping() {
|
||||
if (typeof typing === "number" && +new Date() < typing) return;
|
||||
function startTyping() {
|
||||
if (typeof typing === "number" && +new Date() < typing) return;
|
||||
|
||||
const ws = client.websocket;
|
||||
if (ws.connected) {
|
||||
setTyping(+new Date() + 4000);
|
||||
ws.send({
|
||||
type: "BeginTyping",
|
||||
channel: channel._id,
|
||||
});
|
||||
}
|
||||
}
|
||||
const ws = client.websocket;
|
||||
if (ws.connected) {
|
||||
setTyping(+new Date() + 4000);
|
||||
ws.send({
|
||||
type: "BeginTyping",
|
||||
channel: channel._id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function stopTyping(force?: boolean) {
|
||||
if (force || typing) {
|
||||
const ws = client.websocket;
|
||||
if (ws.connected) {
|
||||
setTyping(false);
|
||||
ws.send({
|
||||
type: "EndTyping",
|
||||
channel: channel._id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
function stopTyping(force?: boolean) {
|
||||
if (force || typing) {
|
||||
const ws = client.websocket;
|
||||
if (ws.connected) {
|
||||
setTyping(false);
|
||||
ws.send({
|
||||
type: "EndTyping",
|
||||
channel: channel._id,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const debouncedStopTyping = useCallback(debounce(stopTyping, 1000), [
|
||||
channel._id,
|
||||
]);
|
||||
const {
|
||||
onChange,
|
||||
onKeyUp,
|
||||
onKeyDown,
|
||||
onFocus,
|
||||
onBlur,
|
||||
...autoCompleteProps
|
||||
} = useAutoComplete(setMessage, {
|
||||
users: { type: "channel", id: channel._id },
|
||||
channels:
|
||||
channel.channel_type === "TextChannel"
|
||||
? { server: channel.server }
|
||||
: undefined,
|
||||
});
|
||||
const debouncedStopTyping = useCallback(debounce(stopTyping, 1000), [
|
||||
channel._id,
|
||||
]);
|
||||
const {
|
||||
onChange,
|
||||
onKeyUp,
|
||||
onKeyDown,
|
||||
onFocus,
|
||||
onBlur,
|
||||
...autoCompleteProps
|
||||
} = useAutoComplete(setMessage, {
|
||||
users: { type: "channel", id: channel._id },
|
||||
channels:
|
||||
channel.channel_type === "TextChannel"
|
||||
? { server: channel.server }
|
||||
: undefined,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<AutoComplete {...autoCompleteProps} />
|
||||
<FilePreview
|
||||
state={uploadState}
|
||||
addFile={() =>
|
||||
uploadState.type === "attached" &&
|
||||
grabFiles(
|
||||
20_000_000,
|
||||
(files) =>
|
||||
setUploadState({
|
||||
type: "attached",
|
||||
files: [...uploadState.files, ...files],
|
||||
}),
|
||||
() =>
|
||||
openScreen({ id: "error", error: "FileTooLarge" }),
|
||||
true,
|
||||
)
|
||||
}
|
||||
removeFile={(index) => {
|
||||
if (uploadState.type !== "attached") return;
|
||||
if (uploadState.files.length === 1) {
|
||||
setUploadState({ type: "none" });
|
||||
} else {
|
||||
setUploadState({
|
||||
type: "attached",
|
||||
files: uploadState.files.filter(
|
||||
(_, i) => index !== i,
|
||||
),
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ReplyBar
|
||||
channel={channel._id}
|
||||
replies={replies}
|
||||
setReplies={setReplies}
|
||||
/>
|
||||
<Base>
|
||||
{permissions & ChannelPermission.UploadFiles ? (
|
||||
<Action>
|
||||
<FileUploader
|
||||
size={24}
|
||||
behaviour="multi"
|
||||
style="attachment"
|
||||
fileType="attachments"
|
||||
maxFileSize={20_000_000}
|
||||
attached={uploadState.type !== "none"}
|
||||
uploading={
|
||||
uploadState.type === "uploading" ||
|
||||
uploadState.type === "sending"
|
||||
}
|
||||
remove={async () =>
|
||||
setUploadState({ type: "none" })
|
||||
}
|
||||
onChange={(files) =>
|
||||
setUploadState({ type: "attached", files })
|
||||
}
|
||||
cancel={() =>
|
||||
uploadState.type === "uploading" &&
|
||||
uploadState.cancel.cancel("cancel")
|
||||
}
|
||||
append={(files) => {
|
||||
if (files.length === 0) return;
|
||||
return (
|
||||
<>
|
||||
<AutoComplete {...autoCompleteProps} />
|
||||
<FilePreview
|
||||
state={uploadState}
|
||||
addFile={() =>
|
||||
uploadState.type === "attached" &&
|
||||
grabFiles(
|
||||
20_000_000,
|
||||
(files) =>
|
||||
setUploadState({
|
||||
type: "attached",
|
||||
files: [...uploadState.files, ...files],
|
||||
}),
|
||||
() =>
|
||||
openScreen({ id: "error", error: "FileTooLarge" }),
|
||||
true,
|
||||
)
|
||||
}
|
||||
removeFile={(index) => {
|
||||
if (uploadState.type !== "attached") return;
|
||||
if (uploadState.files.length === 1) {
|
||||
setUploadState({ type: "none" });
|
||||
} else {
|
||||
setUploadState({
|
||||
type: "attached",
|
||||
files: uploadState.files.filter(
|
||||
(_, i) => index !== i,
|
||||
),
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<ReplyBar
|
||||
channel={channel._id}
|
||||
replies={replies}
|
||||
setReplies={setReplies}
|
||||
/>
|
||||
<Base>
|
||||
{permissions & ChannelPermission.UploadFiles ? (
|
||||
<Action>
|
||||
<FileUploader
|
||||
size={24}
|
||||
behaviour="multi"
|
||||
style="attachment"
|
||||
fileType="attachments"
|
||||
maxFileSize={20_000_000}
|
||||
attached={uploadState.type !== "none"}
|
||||
uploading={
|
||||
uploadState.type === "uploading" ||
|
||||
uploadState.type === "sending"
|
||||
}
|
||||
remove={async () =>
|
||||
setUploadState({ type: "none" })
|
||||
}
|
||||
onChange={(files) =>
|
||||
setUploadState({ type: "attached", files })
|
||||
}
|
||||
cancel={() =>
|
||||
uploadState.type === "uploading" &&
|
||||
uploadState.cancel.cancel("cancel")
|
||||
}
|
||||
append={(files) => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
if (uploadState.type === "none") {
|
||||
setUploadState({ type: "attached", files });
|
||||
} else if (uploadState.type === "attached") {
|
||||
setUploadState({
|
||||
type: "attached",
|
||||
files: [...uploadState.files, ...files],
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Action>
|
||||
) : undefined}
|
||||
<TextAreaAutoSize
|
||||
autoFocus
|
||||
hideBorder
|
||||
maxRows={5}
|
||||
padding={14}
|
||||
id="message"
|
||||
value={draft ?? ""}
|
||||
onKeyUp={onKeyUp}
|
||||
onKeyDown={(e) => {
|
||||
if (onKeyDown(e)) return;
|
||||
if (uploadState.type === "none") {
|
||||
setUploadState({ type: "attached", files });
|
||||
} else if (uploadState.type === "attached") {
|
||||
setUploadState({
|
||||
type: "attached",
|
||||
files: [...uploadState.files, ...files],
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Action>
|
||||
) : undefined}
|
||||
<TextAreaAutoSize
|
||||
autoFocus
|
||||
hideBorder
|
||||
maxRows={5}
|
||||
padding={14}
|
||||
id="message"
|
||||
value={draft ?? ""}
|
||||
onKeyUp={onKeyUp}
|
||||
onKeyDown={(e) => {
|
||||
if (onKeyDown(e)) return;
|
||||
|
||||
if (
|
||||
e.key === "ArrowUp" &&
|
||||
(!draft || draft.length === 0)
|
||||
) {
|
||||
e.preventDefault();
|
||||
internalEmit("MessageRenderer", "edit_last");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
e.key === "ArrowUp" &&
|
||||
(!draft || draft.length === 0)
|
||||
) {
|
||||
e.preventDefault();
|
||||
internalEmit("MessageRenderer", "edit_last");
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!e.shiftKey &&
|
||||
e.key === "Enter" &&
|
||||
!isTouchscreenDevice
|
||||
) {
|
||||
e.preventDefault();
|
||||
return send();
|
||||
}
|
||||
if (
|
||||
!e.shiftKey &&
|
||||
e.key === "Enter" &&
|
||||
!isTouchscreenDevice
|
||||
) {
|
||||
e.preventDefault();
|
||||
return send();
|
||||
}
|
||||
|
||||
debouncedStopTyping(true);
|
||||
}}
|
||||
placeholder={
|
||||
channel.channel_type === "DirectMessage"
|
||||
? translate("app.main.channel.message_who", {
|
||||
person: client.users.get(
|
||||
client.channels.getRecipient(
|
||||
channel._id,
|
||||
),
|
||||
)?.username,
|
||||
})
|
||||
: channel.channel_type === "SavedMessages"
|
||||
? translate("app.main.channel.message_saved")
|
||||
: translate("app.main.channel.message_where", {
|
||||
channel_name: channel.name,
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
uploadState.type === "uploading" ||
|
||||
uploadState.type === "sending"
|
||||
}
|
||||
onChange={(e) => {
|
||||
setMessage(e.currentTarget.value);
|
||||
startTyping();
|
||||
onChange(e);
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
{isTouchscreenDevice && (
|
||||
<Action>
|
||||
<IconButton onClick={send}>
|
||||
<Send size={20} />
|
||||
</IconButton>
|
||||
</Action>
|
||||
)}
|
||||
</Base>
|
||||
</>
|
||||
);
|
||||
debouncedStopTyping(true);
|
||||
}}
|
||||
placeholder={
|
||||
channel.channel_type === "DirectMessage"
|
||||
? translate("app.main.channel.message_who", {
|
||||
person: client.users.get(
|
||||
client.channels.getRecipient(channel._id),
|
||||
)?.username,
|
||||
})
|
||||
: channel.channel_type === "SavedMessages"
|
||||
? translate("app.main.channel.message_saved")
|
||||
: translate("app.main.channel.message_where", {
|
||||
channel_name: channel.name,
|
||||
})
|
||||
}
|
||||
disabled={
|
||||
uploadState.type === "uploading" ||
|
||||
uploadState.type === "sending"
|
||||
}
|
||||
onChange={(e) => {
|
||||
setMessage(e.currentTarget.value);
|
||||
startTyping();
|
||||
onChange(e);
|
||||
}}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
{isTouchscreenDevice && (
|
||||
<Action>
|
||||
<IconButton onClick={send}>
|
||||
<Send size={20} />
|
||||
</IconButton>
|
||||
</Action>
|
||||
)}
|
||||
</Base>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default connectState<Omit<Props, "dispatch" | "draft">>(
|
||||
MessageBox,
|
||||
(state, { channel }) => {
|
||||
return {
|
||||
draft: state.drafts[channel._id],
|
||||
};
|
||||
},
|
||||
true,
|
||||
MessageBox,
|
||||
(state, { channel }) => {
|
||||
return {
|
||||
draft: state.drafts[channel._id],
|
||||
};
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -12,149 +12,149 @@ import UserShort from "../user/UserShort";
|
||||
import MessageBase, { MessageDetail, MessageInfo } from "./MessageBase";
|
||||
|
||||
const SystemContent = styled.div`
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
padding: 2px 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
padding: 2px 0;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
`;
|
||||
|
||||
type SystemMessageParsed =
|
||||
| { type: "text"; content: string }
|
||||
| { type: "user_added"; user: User; by: User }
|
||||
| { type: "user_remove"; user: User; by: User }
|
||||
| { type: "user_joined"; user: User }
|
||||
| { type: "user_left"; user: User }
|
||||
| { type: "user_kicked"; user: User }
|
||||
| { type: "user_banned"; user: User }
|
||||
| { type: "channel_renamed"; name: string; by: User }
|
||||
| { type: "channel_description_changed"; by: User }
|
||||
| { type: "channel_icon_changed"; by: User };
|
||||
| { type: "text"; content: string }
|
||||
| { type: "user_added"; user: User; by: User }
|
||||
| { type: "user_remove"; user: User; by: User }
|
||||
| { type: "user_joined"; user: User }
|
||||
| { type: "user_left"; user: User }
|
||||
| { type: "user_kicked"; user: User }
|
||||
| { type: "user_banned"; user: User }
|
||||
| { type: "channel_renamed"; name: string; by: User }
|
||||
| { type: "channel_description_changed"; by: User }
|
||||
| { type: "channel_icon_changed"; by: User };
|
||||
|
||||
interface Props {
|
||||
attachContext?: boolean;
|
||||
message: MessageObject;
|
||||
attachContext?: boolean;
|
||||
message: MessageObject;
|
||||
}
|
||||
|
||||
export function SystemMessage({ attachContext, message }: Props) {
|
||||
const ctx = useForceUpdate();
|
||||
const ctx = useForceUpdate();
|
||||
|
||||
let data: SystemMessageParsed;
|
||||
let content = message.content;
|
||||
if (typeof content === "object") {
|
||||
switch (content.type) {
|
||||
case "text":
|
||||
data = content;
|
||||
break;
|
||||
case "user_added":
|
||||
case "user_remove":
|
||||
data = {
|
||||
type: content.type,
|
||||
user: useUser(content.id, ctx) as User,
|
||||
by: useUser(content.by, ctx) as User,
|
||||
};
|
||||
break;
|
||||
case "user_joined":
|
||||
case "user_left":
|
||||
case "user_kicked":
|
||||
case "user_banned":
|
||||
data = {
|
||||
type: content.type,
|
||||
user: useUser(content.id, ctx) as User,
|
||||
};
|
||||
break;
|
||||
case "channel_renamed":
|
||||
data = {
|
||||
type: "channel_renamed",
|
||||
name: content.name,
|
||||
by: useUser(content.by, ctx) as User,
|
||||
};
|
||||
break;
|
||||
case "channel_description_changed":
|
||||
case "channel_icon_changed":
|
||||
data = {
|
||||
type: content.type,
|
||||
by: useUser(content.by, ctx) as User,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
data = { type: "text", content: JSON.stringify(content) };
|
||||
}
|
||||
} else {
|
||||
data = { type: "text", content };
|
||||
}
|
||||
let data: SystemMessageParsed;
|
||||
let content = message.content;
|
||||
if (typeof content === "object") {
|
||||
switch (content.type) {
|
||||
case "text":
|
||||
data = content;
|
||||
break;
|
||||
case "user_added":
|
||||
case "user_remove":
|
||||
data = {
|
||||
type: content.type,
|
||||
user: useUser(content.id, ctx) as User,
|
||||
by: useUser(content.by, ctx) as User,
|
||||
};
|
||||
break;
|
||||
case "user_joined":
|
||||
case "user_left":
|
||||
case "user_kicked":
|
||||
case "user_banned":
|
||||
data = {
|
||||
type: content.type,
|
||||
user: useUser(content.id, ctx) as User,
|
||||
};
|
||||
break;
|
||||
case "channel_renamed":
|
||||
data = {
|
||||
type: "channel_renamed",
|
||||
name: content.name,
|
||||
by: useUser(content.by, ctx) as User,
|
||||
};
|
||||
break;
|
||||
case "channel_description_changed":
|
||||
case "channel_icon_changed":
|
||||
data = {
|
||||
type: content.type,
|
||||
by: useUser(content.by, ctx) as User,
|
||||
};
|
||||
break;
|
||||
default:
|
||||
data = { type: "text", content: JSON.stringify(content) };
|
||||
}
|
||||
} else {
|
||||
data = { type: "text", content };
|
||||
}
|
||||
|
||||
let children;
|
||||
switch (data.type) {
|
||||
case "text":
|
||||
children = <span>{data.content}</span>;
|
||||
break;
|
||||
case "user_added":
|
||||
case "user_remove":
|
||||
children = (
|
||||
<TextReact
|
||||
id={`app.main.channel.system.${
|
||||
data.type === "user_added" ? "added_by" : "removed_by"
|
||||
}`}
|
||||
fields={{
|
||||
user: <UserShort user={data.user} />,
|
||||
other_user: <UserShort user={data.by} />,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "user_joined":
|
||||
case "user_left":
|
||||
case "user_kicked":
|
||||
case "user_banned":
|
||||
children = (
|
||||
<TextReact
|
||||
id={`app.main.channel.system.${data.type}`}
|
||||
fields={{
|
||||
user: <UserShort user={data.user} />,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "channel_renamed":
|
||||
children = (
|
||||
<TextReact
|
||||
id={`app.main.channel.system.channel_renamed`}
|
||||
fields={{
|
||||
user: <UserShort user={data.by} />,
|
||||
name: <b>{data.name}</b>,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "channel_description_changed":
|
||||
case "channel_icon_changed":
|
||||
children = (
|
||||
<TextReact
|
||||
id={`app.main.channel.system.${data.type}`}
|
||||
fields={{
|
||||
user: <UserShort user={data.by} />,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
let children;
|
||||
switch (data.type) {
|
||||
case "text":
|
||||
children = <span>{data.content}</span>;
|
||||
break;
|
||||
case "user_added":
|
||||
case "user_remove":
|
||||
children = (
|
||||
<TextReact
|
||||
id={`app.main.channel.system.${
|
||||
data.type === "user_added" ? "added_by" : "removed_by"
|
||||
}`}
|
||||
fields={{
|
||||
user: <UserShort user={data.user} />,
|
||||
other_user: <UserShort user={data.by} />,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "user_joined":
|
||||
case "user_left":
|
||||
case "user_kicked":
|
||||
case "user_banned":
|
||||
children = (
|
||||
<TextReact
|
||||
id={`app.main.channel.system.${data.type}`}
|
||||
fields={{
|
||||
user: <UserShort user={data.user} />,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "channel_renamed":
|
||||
children = (
|
||||
<TextReact
|
||||
id={`app.main.channel.system.channel_renamed`}
|
||||
fields={{
|
||||
user: <UserShort user={data.by} />,
|
||||
name: <b>{data.name}</b>,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
case "channel_description_changed":
|
||||
case "channel_icon_changed":
|
||||
children = (
|
||||
<TextReact
|
||||
id={`app.main.channel.system.${data.type}`}
|
||||
fields={{
|
||||
user: <UserShort user={data.by} />,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<MessageBase
|
||||
onContextMenu={
|
||||
attachContext
|
||||
? attachContextMenu("Menu", {
|
||||
message,
|
||||
contextualChannel: message.channel,
|
||||
})
|
||||
: undefined
|
||||
}>
|
||||
<MessageInfo>
|
||||
<MessageDetail message={message} position="left" />
|
||||
</MessageInfo>
|
||||
<SystemContent>{children}</SystemContent>
|
||||
</MessageBase>
|
||||
);
|
||||
return (
|
||||
<MessageBase
|
||||
onContextMenu={
|
||||
attachContext
|
||||
? attachContextMenu("Menu", {
|
||||
message,
|
||||
contextualChannel: message.channel,
|
||||
})
|
||||
: undefined
|
||||
}>
|
||||
<MessageInfo>
|
||||
<MessageDetail message={message} position="left" />
|
||||
</MessageInfo>
|
||||
<SystemContent>{children}</SystemContent>
|
||||
</MessageBase>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,121 +13,121 @@ import AttachmentActions from "./AttachmentActions";
|
||||
import TextFile from "./TextFile";
|
||||
|
||||
interface Props {
|
||||
attachment: AttachmentRJS;
|
||||
hasContent: boolean;
|
||||
attachment: AttachmentRJS;
|
||||
hasContent: boolean;
|
||||
}
|
||||
|
||||
const MAX_ATTACHMENT_WIDTH = 480;
|
||||
|
||||
export default function Attachment({ attachment, hasContent }: Props) {
|
||||
const client = useContext(AppContext);
|
||||
const { openScreen } = useIntermediate();
|
||||
const { filename, metadata } = attachment;
|
||||
const [spoiler, setSpoiler] = useState(filename.startsWith("SPOILER_"));
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const client = useContext(AppContext);
|
||||
const { openScreen } = useIntermediate();
|
||||
const { filename, metadata } = attachment;
|
||||
const [spoiler, setSpoiler] = useState(filename.startsWith("SPOILER_"));
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
|
||||
const url = client.generateFileURL(
|
||||
attachment,
|
||||
{ width: MAX_ATTACHMENT_WIDTH * 1.5 },
|
||||
true,
|
||||
);
|
||||
const url = client.generateFileURL(
|
||||
attachment,
|
||||
{ width: MAX_ATTACHMENT_WIDTH * 1.5 },
|
||||
true,
|
||||
);
|
||||
|
||||
switch (metadata.type) {
|
||||
case "Image": {
|
||||
return (
|
||||
<div
|
||||
className={styles.container}
|
||||
onClick={() => spoiler && setSpoiler(false)}>
|
||||
{spoiler && (
|
||||
<div className={styles.overflow}>
|
||||
<span>
|
||||
<Text id="app.main.channel.misc.spoiler_attachment" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={url}
|
||||
alt={filename}
|
||||
width={metadata.width}
|
||||
height={metadata.height}
|
||||
data-spoiler={spoiler}
|
||||
data-has-content={hasContent}
|
||||
className={classNames(
|
||||
styles.attachment,
|
||||
styles.image,
|
||||
loaded && styles.loaded,
|
||||
)}
|
||||
onClick={() =>
|
||||
openScreen({ id: "image_viewer", attachment })
|
||||
}
|
||||
onMouseDown={(ev) =>
|
||||
ev.button === 1 && window.open(url, "_blank")
|
||||
}
|
||||
onLoad={() => setLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "Audio": {
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.attachment, styles.audio)}
|
||||
data-has-content={hasContent}>
|
||||
<AttachmentActions attachment={attachment} />
|
||||
<audio src={url} controls />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "Video": {
|
||||
return (
|
||||
<div
|
||||
className={styles.container}
|
||||
onClick={() => spoiler && setSpoiler(false)}>
|
||||
{spoiler && (
|
||||
<div className={styles.overflow}>
|
||||
<span>
|
||||
<Text id="app.main.channel.misc.spoiler_attachment" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
data-spoiler={spoiler}
|
||||
data-has-content={hasContent}
|
||||
className={classNames(styles.attachment, styles.video)}>
|
||||
<AttachmentActions attachment={attachment} />
|
||||
<video
|
||||
src={url}
|
||||
width={metadata.width}
|
||||
height={metadata.height}
|
||||
className={classNames(loaded && styles.loaded)}
|
||||
controls
|
||||
onMouseDown={(ev) =>
|
||||
ev.button === 1 && window.open(url, "_blank")
|
||||
}
|
||||
onLoadedMetadata={() => setLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "Text": {
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.attachment, styles.text)}
|
||||
data-has-content={hasContent}>
|
||||
<TextFile attachment={attachment} />
|
||||
<AttachmentActions attachment={attachment} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
default: {
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.attachment, styles.file)}
|
||||
data-has-content={hasContent}>
|
||||
<AttachmentActions attachment={attachment} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
switch (metadata.type) {
|
||||
case "Image": {
|
||||
return (
|
||||
<div
|
||||
className={styles.container}
|
||||
onClick={() => spoiler && setSpoiler(false)}>
|
||||
{spoiler && (
|
||||
<div className={styles.overflow}>
|
||||
<span>
|
||||
<Text id="app.main.channel.misc.spoiler_attachment" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<img
|
||||
src={url}
|
||||
alt={filename}
|
||||
width={metadata.width}
|
||||
height={metadata.height}
|
||||
data-spoiler={spoiler}
|
||||
data-has-content={hasContent}
|
||||
className={classNames(
|
||||
styles.attachment,
|
||||
styles.image,
|
||||
loaded && styles.loaded,
|
||||
)}
|
||||
onClick={() =>
|
||||
openScreen({ id: "image_viewer", attachment })
|
||||
}
|
||||
onMouseDown={(ev) =>
|
||||
ev.button === 1 && window.open(url, "_blank")
|
||||
}
|
||||
onLoad={() => setLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "Audio": {
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.attachment, styles.audio)}
|
||||
data-has-content={hasContent}>
|
||||
<AttachmentActions attachment={attachment} />
|
||||
<audio src={url} controls />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "Video": {
|
||||
return (
|
||||
<div
|
||||
className={styles.container}
|
||||
onClick={() => spoiler && setSpoiler(false)}>
|
||||
{spoiler && (
|
||||
<div className={styles.overflow}>
|
||||
<span>
|
||||
<Text id="app.main.channel.misc.spoiler_attachment" />
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
data-spoiler={spoiler}
|
||||
data-has-content={hasContent}
|
||||
className={classNames(styles.attachment, styles.video)}>
|
||||
<AttachmentActions attachment={attachment} />
|
||||
<video
|
||||
src={url}
|
||||
width={metadata.width}
|
||||
height={metadata.height}
|
||||
className={classNames(loaded && styles.loaded)}
|
||||
controls
|
||||
onMouseDown={(ev) =>
|
||||
ev.button === 1 && window.open(url, "_blank")
|
||||
}
|
||||
onLoadedMetadata={() => setLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "Text": {
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.attachment, styles.text)}
|
||||
data-has-content={hasContent}>
|
||||
<TextFile attachment={attachment} />
|
||||
<AttachmentActions attachment={attachment} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
default: {
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.attachment, styles.file)}
|
||||
data-has-content={hasContent}>
|
||||
<AttachmentActions attachment={attachment} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import {
|
||||
Download,
|
||||
LinkExternal,
|
||||
File,
|
||||
Headphone,
|
||||
Video,
|
||||
Download,
|
||||
LinkExternal,
|
||||
File,
|
||||
Headphone,
|
||||
Video,
|
||||
} from "@styled-icons/boxicons-regular";
|
||||
import { Attachment } from "revolt.js/dist/api/objects";
|
||||
|
||||
@@ -18,98 +18,98 @@ import { AppContext } from "../../../../context/revoltjs/RevoltClient";
|
||||
import IconButton from "../../../ui/IconButton";
|
||||
|
||||
interface Props {
|
||||
attachment: Attachment;
|
||||
attachment: Attachment;
|
||||
}
|
||||
|
||||
export default function AttachmentActions({ attachment }: Props) {
|
||||
const client = useContext(AppContext);
|
||||
const { filename, metadata, size } = attachment;
|
||||
const client = useContext(AppContext);
|
||||
const { filename, metadata, size } = attachment;
|
||||
|
||||
const url = client.generateFileURL(attachment)!;
|
||||
const open_url = `${url}/${filename}`;
|
||||
const download_url = url.replace("attachments", "attachments/download");
|
||||
const url = client.generateFileURL(attachment)!;
|
||||
const open_url = `${url}/${filename}`;
|
||||
const download_url = url.replace("attachments", "attachments/download");
|
||||
|
||||
const filesize = determineFileSize(size);
|
||||
const filesize = determineFileSize(size);
|
||||
|
||||
switch (metadata.type) {
|
||||
case "Image":
|
||||
return (
|
||||
<div className={classNames(styles.actions, styles.imageAction)}>
|
||||
<span className={styles.filename}>{filename}</span>
|
||||
<span className={styles.filesize}>
|
||||
{metadata.width + "x" + metadata.height} ({filesize})
|
||||
</span>
|
||||
<a
|
||||
href={open_url}
|
||||
target="_blank"
|
||||
className={styles.iconType}>
|
||||
<IconButton>
|
||||
<LinkExternal size={24} />
|
||||
</IconButton>
|
||||
</a>
|
||||
<a
|
||||
href={download_url}
|
||||
className={styles.downloadIcon}
|
||||
download
|
||||
target="_blank">
|
||||
<IconButton>
|
||||
<Download size={24} />
|
||||
</IconButton>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
case "Audio":
|
||||
return (
|
||||
<div className={classNames(styles.actions, styles.audioAction)}>
|
||||
<Headphone size={24} className={styles.iconType} />
|
||||
<span className={styles.filename}>{filename}</span>
|
||||
<span className={styles.filesize}>{filesize}</span>
|
||||
<a
|
||||
href={download_url}
|
||||
className={styles.downloadIcon}
|
||||
download
|
||||
target="_blank">
|
||||
<IconButton>
|
||||
<Download size={24} />
|
||||
</IconButton>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
case "Video":
|
||||
return (
|
||||
<div className={classNames(styles.actions, styles.videoAction)}>
|
||||
<Video size={24} className={styles.iconType} />
|
||||
<span className={styles.filename}>{filename}</span>
|
||||
<span className={styles.filesize}>
|
||||
{metadata.width + "x" + metadata.height} ({filesize})
|
||||
</span>
|
||||
<a
|
||||
href={download_url}
|
||||
className={styles.downloadIcon}
|
||||
download
|
||||
target="_blank">
|
||||
<IconButton>
|
||||
<Download size={24} />
|
||||
</IconButton>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className={styles.actions}>
|
||||
<File size={24} className={styles.iconType} />
|
||||
<span className={styles.filename}>{filename}</span>
|
||||
<span className={styles.filesize}>{filesize}</span>
|
||||
<a
|
||||
href={download_url}
|
||||
className={styles.downloadIcon}
|
||||
download
|
||||
target="_blank">
|
||||
<IconButton>
|
||||
<Download size={24} />
|
||||
</IconButton>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
switch (metadata.type) {
|
||||
case "Image":
|
||||
return (
|
||||
<div className={classNames(styles.actions, styles.imageAction)}>
|
||||
<span className={styles.filename}>{filename}</span>
|
||||
<span className={styles.filesize}>
|
||||
{metadata.width + "x" + metadata.height} ({filesize})
|
||||
</span>
|
||||
<a
|
||||
href={open_url}
|
||||
target="_blank"
|
||||
className={styles.iconType}>
|
||||
<IconButton>
|
||||
<LinkExternal size={24} />
|
||||
</IconButton>
|
||||
</a>
|
||||
<a
|
||||
href={download_url}
|
||||
className={styles.downloadIcon}
|
||||
download
|
||||
target="_blank">
|
||||
<IconButton>
|
||||
<Download size={24} />
|
||||
</IconButton>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
case "Audio":
|
||||
return (
|
||||
<div className={classNames(styles.actions, styles.audioAction)}>
|
||||
<Headphone size={24} className={styles.iconType} />
|
||||
<span className={styles.filename}>{filename}</span>
|
||||
<span className={styles.filesize}>{filesize}</span>
|
||||
<a
|
||||
href={download_url}
|
||||
className={styles.downloadIcon}
|
||||
download
|
||||
target="_blank">
|
||||
<IconButton>
|
||||
<Download size={24} />
|
||||
</IconButton>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
case "Video":
|
||||
return (
|
||||
<div className={classNames(styles.actions, styles.videoAction)}>
|
||||
<Video size={24} className={styles.iconType} />
|
||||
<span className={styles.filename}>{filename}</span>
|
||||
<span className={styles.filesize}>
|
||||
{metadata.width + "x" + metadata.height} ({filesize})
|
||||
</span>
|
||||
<a
|
||||
href={download_url}
|
||||
className={styles.downloadIcon}
|
||||
download
|
||||
target="_blank">
|
||||
<IconButton>
|
||||
<Download size={24} />
|
||||
</IconButton>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className={styles.actions}>
|
||||
<File size={24} className={styles.iconType} />
|
||||
<span className={styles.filename}>{filename}</span>
|
||||
<span className={styles.filesize}>{filesize}</span>
|
||||
<a
|
||||
href={download_url}
|
||||
className={styles.downloadIcon}
|
||||
download
|
||||
target="_blank">
|
||||
<IconButton>
|
||||
<Download size={24} />
|
||||
</IconButton>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,83 +11,83 @@ import Markdown from "../../../markdown/Markdown";
|
||||
import UserShort from "../../user/UserShort";
|
||||
|
||||
interface Props {
|
||||
channel: string;
|
||||
index: number;
|
||||
id: string;
|
||||
channel: string;
|
||||
index: number;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const ReplyBase = styled.div<{
|
||||
head?: boolean;
|
||||
fail?: boolean;
|
||||
preview?: boolean;
|
||||
head?: boolean;
|
||||
fail?: boolean;
|
||||
preview?: boolean;
|
||||
}>`
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
font-size: 0.8em;
|
||||
margin-left: 30px;
|
||||
user-select: none;
|
||||
margin-bottom: 4px;
|
||||
align-items: center;
|
||||
color: var(--secondary-foreground);
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
font-size: 0.8em;
|
||||
margin-left: 30px;
|
||||
user-select: none;
|
||||
margin-bottom: 4px;
|
||||
align-items: center;
|
||||
color: var(--secondary-foreground);
|
||||
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
svg:first-child {
|
||||
flex-shrink: 0;
|
||||
transform: scaleX(-1);
|
||||
color: var(--tertiary-foreground);
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.fail &&
|
||||
css`
|
||||
color: var(--tertiary-foreground);
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.head &&
|
||||
css`
|
||||
margin-top: 12px;
|
||||
`}
|
||||
svg:first-child {
|
||||
flex-shrink: 0;
|
||||
transform: scaleX(-1);
|
||||
color: var(--tertiary-foreground);
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.preview &&
|
||||
css`
|
||||
margin-left: 0;
|
||||
`}
|
||||
props.fail &&
|
||||
css`
|
||||
color: var(--tertiary-foreground);
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.head &&
|
||||
css`
|
||||
margin-top: 12px;
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.preview &&
|
||||
css`
|
||||
margin-left: 0;
|
||||
`}
|
||||
`;
|
||||
|
||||
export function MessageReply({ index, channel, id }: Props) {
|
||||
const view = useRenderState(channel);
|
||||
if (view?.type !== "RENDER") return null;
|
||||
const view = useRenderState(channel);
|
||||
if (view?.type !== "RENDER") return null;
|
||||
|
||||
const message = view.messages.find((x) => x._id === id);
|
||||
if (!message) {
|
||||
return (
|
||||
<ReplyBase head={index === 0} fail>
|
||||
<Reply size={16} />
|
||||
<span>
|
||||
<Text id="app.main.channel.misc.failed_load" />
|
||||
</span>
|
||||
</ReplyBase>
|
||||
);
|
||||
}
|
||||
const message = view.messages.find((x) => x._id === id);
|
||||
if (!message) {
|
||||
return (
|
||||
<ReplyBase head={index === 0} fail>
|
||||
<Reply size={16} />
|
||||
<span>
|
||||
<Text id="app.main.channel.misc.failed_load" />
|
||||
</span>
|
||||
</ReplyBase>
|
||||
);
|
||||
}
|
||||
|
||||
const user = useUser(message.author);
|
||||
const user = useUser(message.author);
|
||||
|
||||
return (
|
||||
<ReplyBase head={index === 0}>
|
||||
<Reply size={16} />
|
||||
<UserShort user={user} size={16} />
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<File size={16} />
|
||||
)}
|
||||
<Markdown
|
||||
disallowBigEmoji
|
||||
content={(message.content as string).replace(/\n/g, " ")}
|
||||
/>
|
||||
</ReplyBase>
|
||||
);
|
||||
return (
|
||||
<ReplyBase head={index === 0}>
|
||||
<Reply size={16} />
|
||||
<UserShort user={user} size={16} />
|
||||
{message.attachments && message.attachments.length > 0 && (
|
||||
<File size={16} />
|
||||
)}
|
||||
<Markdown
|
||||
disallowBigEmoji
|
||||
content={(message.content as string).replace(/\n/g, " ")}
|
||||
/>
|
||||
</ReplyBase>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,67 +6,67 @@ import { useContext, useEffect, useState } from "preact/hooks";
|
||||
|
||||
import RequiresOnline from "../../../../context/revoltjs/RequiresOnline";
|
||||
import {
|
||||
AppContext,
|
||||
StatusContext,
|
||||
AppContext,
|
||||
StatusContext,
|
||||
} from "../../../../context/revoltjs/RevoltClient";
|
||||
|
||||
import Preloader from "../../../ui/Preloader";
|
||||
|
||||
interface Props {
|
||||
attachment: Attachment;
|
||||
attachment: Attachment;
|
||||
}
|
||||
|
||||
const fileCache: { [key: string]: string } = {};
|
||||
|
||||
export default function TextFile({ attachment }: Props) {
|
||||
const [content, setContent] = useState<undefined | string>(undefined);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const status = useContext(StatusContext);
|
||||
const client = useContext(AppContext);
|
||||
const [content, setContent] = useState<undefined | string>(undefined);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const status = useContext(StatusContext);
|
||||
const client = useContext(AppContext);
|
||||
|
||||
const url = client.generateFileURL(attachment)!;
|
||||
const url = client.generateFileURL(attachment)!;
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof content !== "undefined") return;
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
useEffect(() => {
|
||||
if (typeof content !== "undefined") return;
|
||||
if (loading) return;
|
||||
setLoading(true);
|
||||
|
||||
let cached = fileCache[attachment._id];
|
||||
if (cached) {
|
||||
setContent(cached);
|
||||
setLoading(false);
|
||||
} else {
|
||||
axios
|
||||
.get(url)
|
||||
.then((res) => {
|
||||
setContent(res.data);
|
||||
fileCache[attachment._id] = res.data;
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
console.error(
|
||||
"Failed to load text file. [",
|
||||
attachment._id,
|
||||
"]",
|
||||
);
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [content, loading, status]);
|
||||
let cached = fileCache[attachment._id];
|
||||
if (cached) {
|
||||
setContent(cached);
|
||||
setLoading(false);
|
||||
} else {
|
||||
axios
|
||||
.get(url)
|
||||
.then((res) => {
|
||||
setContent(res.data);
|
||||
fileCache[attachment._id] = res.data;
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
console.error(
|
||||
"Failed to load text file. [",
|
||||
attachment._id,
|
||||
"]",
|
||||
);
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
}, [content, loading, status]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.textContent}
|
||||
data-loading={typeof content === "undefined"}>
|
||||
{content ? (
|
||||
<pre>
|
||||
<code>{content}</code>
|
||||
</pre>
|
||||
) : (
|
||||
<RequiresOnline>
|
||||
<Preloader type="ring" />
|
||||
</RequiresOnline>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
className={styles.textContent}
|
||||
data-loading={typeof content === "undefined"}>
|
||||
{content ? (
|
||||
<pre>
|
||||
<code>{content}</code>
|
||||
</pre>
|
||||
) : (
|
||||
<RequiresOnline>
|
||||
<Preloader type="ring" />
|
||||
</RequiresOnline>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,225 +9,225 @@ import { determineFileSize } from "../../../../lib/fileSize";
|
||||
import { CAN_UPLOAD_AT_ONCE, UploadState } from "../MessageBox";
|
||||
|
||||
interface Props {
|
||||
state: UploadState;
|
||||
addFile: () => void;
|
||||
removeFile: (index: number) => void;
|
||||
state: UploadState;
|
||||
addFile: () => void;
|
||||
removeFile: (index: number) => void;
|
||||
}
|
||||
|
||||
const Container = styled.div`
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
user-select: none;
|
||||
flex-direction: column;
|
||||
background: var(--message-box);
|
||||
gap: 4px;
|
||||
padding: 8px;
|
||||
display: flex;
|
||||
user-select: none;
|
||||
flex-direction: column;
|
||||
background: var(--message-box);
|
||||
`;
|
||||
|
||||
const Carousel = styled.div`
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
overflow-x: scroll;
|
||||
flex-direction: row;
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
overflow-x: scroll;
|
||||
flex-direction: row;
|
||||
`;
|
||||
|
||||
const Entry = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
&.fade {
|
||||
opacity: 0.4;
|
||||
}
|
||||
&.fade {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
span.fn {
|
||||
margin: auto;
|
||||
font-size: 0.8em;
|
||||
overflow: hidden;
|
||||
max-width: 180px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--secondary-foreground);
|
||||
}
|
||||
span.fn {
|
||||
margin: auto;
|
||||
font-size: 0.8em;
|
||||
overflow: hidden;
|
||||
max-width: 180px;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--secondary-foreground);
|
||||
}
|
||||
|
||||
span.size {
|
||||
font-size: 0.6em;
|
||||
color: var(--tertiary-foreground);
|
||||
text-align: center;
|
||||
}
|
||||
span.size {
|
||||
font-size: 0.6em;
|
||||
color: var(--tertiary-foreground);
|
||||
text-align: center;
|
||||
}
|
||||
`;
|
||||
|
||||
const Description = styled.div`
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
font-size: 0.9em;
|
||||
align-items: center;
|
||||
color: var(--secondary-foreground);
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
font-size: 0.9em;
|
||||
align-items: center;
|
||||
color: var(--secondary-foreground);
|
||||
`;
|
||||
|
||||
const Divider = styled.div`
|
||||
width: 4px;
|
||||
height: 130px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
background: var(--tertiary-background);
|
||||
width: 4px;
|
||||
height: 130px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
background: var(--tertiary-background);
|
||||
`;
|
||||
|
||||
const EmptyEntry = styled.div`
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
place-items: center;
|
||||
background: var(--primary-background);
|
||||
transition: 0.1s ease background-color;
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
place-items: center;
|
||||
background: var(--primary-background);
|
||||
transition: 0.1s ease background-color;
|
||||
|
||||
&:hover {
|
||||
background: var(--secondary-background);
|
||||
}
|
||||
&:hover {
|
||||
background: var(--secondary-background);
|
||||
}
|
||||
`;
|
||||
|
||||
const PreviewBox = styled.div`
|
||||
display: grid;
|
||||
grid-template: "main" 100px / minmax(100px, 1fr);
|
||||
justify-items: center;
|
||||
display: grid;
|
||||
grid-template: "main" 100px / minmax(100px, 1fr);
|
||||
justify-items: center;
|
||||
|
||||
background: var(--primary-background);
|
||||
background: var(--primary-background);
|
||||
|
||||
overflow: hidden;
|
||||
overflow: hidden;
|
||||
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
|
||||
.icon,
|
||||
.overlay {
|
||||
grid-area: main;
|
||||
}
|
||||
.icon,
|
||||
.overlay {
|
||||
grid-area: main;
|
||||
}
|
||||
|
||||
.icon {
|
||||
height: 100px;
|
||||
width: 100%;
|
||||
margin-bottom: 4px;
|
||||
object-fit: contain;
|
||||
}
|
||||
.icon {
|
||||
height: 100px;
|
||||
width: 100%;
|
||||
margin-bottom: 4px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.overlay {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
|
||||
transition: 0.1s ease opacity;
|
||||
}
|
||||
transition: 0.1s ease opacity;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.overlay {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
.overlay {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function FileEntry({
|
||||
file,
|
||||
remove,
|
||||
index,
|
||||
file,
|
||||
remove,
|
||||
index,
|
||||
}: {
|
||||
file: File;
|
||||
remove?: () => void;
|
||||
index: number;
|
||||
file: File;
|
||||
remove?: () => void;
|
||||
index: number;
|
||||
}) {
|
||||
if (!file.type.startsWith("image/"))
|
||||
return (
|
||||
<Entry className={index >= CAN_UPLOAD_AT_ONCE ? "fade" : ""}>
|
||||
<PreviewBox onClick={remove}>
|
||||
<EmptyEntry className="icon">
|
||||
<File size={36} />
|
||||
</EmptyEntry>
|
||||
<div class="overlay">
|
||||
<XCircle size={36} />
|
||||
</div>
|
||||
</PreviewBox>
|
||||
<span class="fn">{file.name}</span>
|
||||
<span class="size">{determineFileSize(file.size)}</span>
|
||||
</Entry>
|
||||
);
|
||||
if (!file.type.startsWith("image/"))
|
||||
return (
|
||||
<Entry className={index >= CAN_UPLOAD_AT_ONCE ? "fade" : ""}>
|
||||
<PreviewBox onClick={remove}>
|
||||
<EmptyEntry className="icon">
|
||||
<File size={36} />
|
||||
</EmptyEntry>
|
||||
<div class="overlay">
|
||||
<XCircle size={36} />
|
||||
</div>
|
||||
</PreviewBox>
|
||||
<span class="fn">{file.name}</span>
|
||||
<span class="size">{determineFileSize(file.size)}</span>
|
||||
</Entry>
|
||||
);
|
||||
|
||||
const [url, setURL] = useState("");
|
||||
const [url, setURL] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
let url: string = URL.createObjectURL(file);
|
||||
setURL(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file]);
|
||||
useEffect(() => {
|
||||
let url: string = URL.createObjectURL(file);
|
||||
setURL(url);
|
||||
return () => URL.revokeObjectURL(url);
|
||||
}, [file]);
|
||||
|
||||
return (
|
||||
<Entry className={index >= CAN_UPLOAD_AT_ONCE ? "fade" : ""}>
|
||||
<PreviewBox onClick={remove}>
|
||||
<img class="icon" src={url} alt={file.name} />
|
||||
<div class="overlay">
|
||||
<XCircle size={36} />
|
||||
</div>
|
||||
</PreviewBox>
|
||||
<span class="fn">{file.name}</span>
|
||||
<span class="size">{determineFileSize(file.size)}</span>
|
||||
</Entry>
|
||||
);
|
||||
return (
|
||||
<Entry className={index >= CAN_UPLOAD_AT_ONCE ? "fade" : ""}>
|
||||
<PreviewBox onClick={remove}>
|
||||
<img class="icon" src={url} alt={file.name} />
|
||||
<div class="overlay">
|
||||
<XCircle size={36} />
|
||||
</div>
|
||||
</PreviewBox>
|
||||
<span class="fn">{file.name}</span>
|
||||
<span class="size">{determineFileSize(file.size)}</span>
|
||||
</Entry>
|
||||
);
|
||||
}
|
||||
|
||||
export default function FilePreview({ state, addFile, removeFile }: Props) {
|
||||
if (state.type === "none") return null;
|
||||
if (state.type === "none") return null;
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<Carousel>
|
||||
{state.files.map((file, index) => (
|
||||
<>
|
||||
{index === CAN_UPLOAD_AT_ONCE && <Divider />}
|
||||
<FileEntry
|
||||
index={index}
|
||||
file={file}
|
||||
key={file.name}
|
||||
remove={
|
||||
state.type === "attached"
|
||||
? () => removeFile(index)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
{state.type === "attached" && (
|
||||
<EmptyEntry onClick={addFile}>
|
||||
<Plus size={48} />
|
||||
</EmptyEntry>
|
||||
)}
|
||||
</Carousel>
|
||||
{state.type === "uploading" && (
|
||||
<Description>
|
||||
<Share size={24} />
|
||||
<Text id="app.main.channel.uploading_file" /> (
|
||||
{state.percent}%)
|
||||
</Description>
|
||||
)}
|
||||
{state.type === "sending" && (
|
||||
<Description>
|
||||
<Share size={24} />
|
||||
Sending...
|
||||
</Description>
|
||||
)}
|
||||
{state.type === "failed" && (
|
||||
<Description>
|
||||
<X size={24} />
|
||||
<Text id={`error.${state.error}`} />
|
||||
</Description>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
return (
|
||||
<Container>
|
||||
<Carousel>
|
||||
{state.files.map((file, index) => (
|
||||
<>
|
||||
{index === CAN_UPLOAD_AT_ONCE && <Divider />}
|
||||
<FileEntry
|
||||
index={index}
|
||||
file={file}
|
||||
key={file.name}
|
||||
remove={
|
||||
state.type === "attached"
|
||||
? () => removeFile(index)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
))}
|
||||
{state.type === "attached" && (
|
||||
<EmptyEntry onClick={addFile}>
|
||||
<Plus size={48} />
|
||||
</EmptyEntry>
|
||||
)}
|
||||
</Carousel>
|
||||
{state.type === "uploading" && (
|
||||
<Description>
|
||||
<Share size={24} />
|
||||
<Text id="app.main.channel.uploading_file" /> (
|
||||
{state.percent}%)
|
||||
</Description>
|
||||
)}
|
||||
{state.type === "sending" && (
|
||||
<Description>
|
||||
<Share size={24} />
|
||||
Sending...
|
||||
</Description>
|
||||
)}
|
||||
{state.type === "failed" && (
|
||||
<Description>
|
||||
<X size={24} />
|
||||
<Text id={`error.${state.error}`} />
|
||||
</Description>
|
||||
)}
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,61 +4,61 @@ import styled from "styled-components";
|
||||
import { Text } from "preact-i18n";
|
||||
|
||||
import {
|
||||
SingletonMessageRenderer,
|
||||
useRenderState,
|
||||
SingletonMessageRenderer,
|
||||
useRenderState,
|
||||
} from "../../../../lib/renderer/Singleton";
|
||||
|
||||
const Bar = styled.div`
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
position: relative;
|
||||
|
||||
> div {
|
||||
top: -26px;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
border-radius: 4px 4px 0 0;
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 4px 8px;
|
||||
user-select: none;
|
||||
color: var(--secondary-foreground);
|
||||
background: var(--secondary-background);
|
||||
justify-content: space-between;
|
||||
transition: color ease-in-out 0.08s;
|
||||
> div {
|
||||
top: -26px;
|
||||
width: 100%;
|
||||
position: absolute;
|
||||
border-radius: 4px 4px 0 0;
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 4px 8px;
|
||||
user-select: none;
|
||||
color: var(--secondary-foreground);
|
||||
background: var(--secondary-background);
|
||||
justify-content: space-between;
|
||||
transition: color ease-in-out 0.08s;
|
||||
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
> div {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--primary-text);
|
||||
}
|
||||
&:hover {
|
||||
color: var(--primary-text);
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
}
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export default function JumpToBottom({ id }: { id: string }) {
|
||||
const view = useRenderState(id);
|
||||
if (!view || view.type !== "RENDER" || view.atBottom) return null;
|
||||
const view = useRenderState(id);
|
||||
if (!view || view.type !== "RENDER" || view.atBottom) return null;
|
||||
|
||||
return (
|
||||
<Bar>
|
||||
<div
|
||||
onClick={() => SingletonMessageRenderer.jumpToBottom(id, true)}>
|
||||
<div>
|
||||
<Text id="app.main.channel.misc.viewing_old" />
|
||||
</div>
|
||||
<div>
|
||||
<Text id="app.main.channel.misc.jump_present" />{" "}
|
||||
<DownArrow size={18} />
|
||||
</div>
|
||||
</div>
|
||||
</Bar>
|
||||
);
|
||||
return (
|
||||
<Bar>
|
||||
<div
|
||||
onClick={() => SingletonMessageRenderer.jumpToBottom(id, true)}>
|
||||
<div>
|
||||
<Text id="app.main.channel.misc.viewing_old" />
|
||||
</div>
|
||||
<div>
|
||||
<Text id="app.main.channel.misc.jump_present" />{" "}
|
||||
<DownArrow size={18} />
|
||||
</div>
|
||||
</div>
|
||||
</Bar>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
At,
|
||||
Reply as ReplyIcon,
|
||||
File,
|
||||
XCircle,
|
||||
At,
|
||||
Reply as ReplyIcon,
|
||||
File,
|
||||
XCircle,
|
||||
} from "@styled-icons/boxicons-regular";
|
||||
import styled from "styled-components";
|
||||
|
||||
@@ -23,119 +23,119 @@ import UserShort from "../../user/UserShort";
|
||||
import { ReplyBase } from "../attachments/MessageReply";
|
||||
|
||||
interface Props {
|
||||
channel: string;
|
||||
replies: Reply[];
|
||||
setReplies: StateUpdater<Reply[]>;
|
||||
channel: string;
|
||||
replies: Reply[];
|
||||
setReplies: StateUpdater<Reply[]>;
|
||||
}
|
||||
|
||||
const Base = styled.div`
|
||||
display: flex;
|
||||
padding: 0 22px;
|
||||
user-select: none;
|
||||
align-items: center;
|
||||
background: var(--message-box);
|
||||
display: flex;
|
||||
padding: 0 22px;
|
||||
user-select: none;
|
||||
align-items: center;
|
||||
background: var(--message-box);
|
||||
|
||||
div {
|
||||
flex-grow: 1;
|
||||
}
|
||||
div {
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.actions {
|
||||
gap: 12px;
|
||||
display: flex;
|
||||
}
|
||||
.actions {
|
||||
gap: 12px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
font-size: 0.7em;
|
||||
align-items: center;
|
||||
}
|
||||
.toggle {
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
font-size: 0.7em;
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
|
||||
// ! FIXME: Move to global config
|
||||
const MAX_REPLIES = 5;
|
||||
export default function ReplyBar({ channel, replies, setReplies }: Props) {
|
||||
useEffect(() => {
|
||||
return internalSubscribe(
|
||||
"ReplyBar",
|
||||
"add",
|
||||
(id) =>
|
||||
replies.length < MAX_REPLIES &&
|
||||
!replies.find((x) => x.id === id) &&
|
||||
setReplies([...replies, { id, mention: false }]),
|
||||
);
|
||||
}, [replies]);
|
||||
useEffect(() => {
|
||||
return internalSubscribe(
|
||||
"ReplyBar",
|
||||
"add",
|
||||
(id) =>
|
||||
replies.length < MAX_REPLIES &&
|
||||
!replies.find((x) => x.id === id) &&
|
||||
setReplies([...replies, { id, mention: false }]),
|
||||
);
|
||||
}, [replies]);
|
||||
|
||||
const view = useRenderState(channel);
|
||||
if (view?.type !== "RENDER") return null;
|
||||
const view = useRenderState(channel);
|
||||
if (view?.type !== "RENDER") return null;
|
||||
|
||||
const ids = replies.map((x) => x.id);
|
||||
const messages = view.messages.filter((x) => ids.includes(x._id));
|
||||
const users = useUsers(messages.map((x) => x.author));
|
||||
const ids = replies.map((x) => x.id);
|
||||
const messages = view.messages.filter((x) => ids.includes(x._id));
|
||||
const users = useUsers(messages.map((x) => x.author));
|
||||
|
||||
return (
|
||||
<div>
|
||||
{replies.map((reply, index) => {
|
||||
let message = messages.find((x) => reply.id === x._id);
|
||||
// ! FIXME: better solution would be to
|
||||
// ! have a hook for resolving messages from
|
||||
// ! render state along with relevant users
|
||||
// -> which then fetches any unknown messages
|
||||
if (!message)
|
||||
return (
|
||||
<span>
|
||||
<Text id="app.main.channel.misc.failed_load" />
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
{replies.map((reply, index) => {
|
||||
let message = messages.find((x) => reply.id === x._id);
|
||||
// ! FIXME: better solution would be to
|
||||
// ! have a hook for resolving messages from
|
||||
// ! render state along with relevant users
|
||||
// -> which then fetches any unknown messages
|
||||
if (!message)
|
||||
return (
|
||||
<span>
|
||||
<Text id="app.main.channel.misc.failed_load" />
|
||||
</span>
|
||||
);
|
||||
|
||||
let user = users.find((x) => message!.author === x?._id);
|
||||
if (!user) return;
|
||||
let user = users.find((x) => message!.author === x?._id);
|
||||
if (!user) return;
|
||||
|
||||
return (
|
||||
<Base key={reply.id}>
|
||||
<ReplyBase preview>
|
||||
<ReplyIcon size={22} />
|
||||
<UserShort user={user} size={16} />
|
||||
{message.attachments &&
|
||||
message.attachments.length > 0 && (
|
||||
<File size={16} />
|
||||
)}
|
||||
<Markdown
|
||||
disallowBigEmoji
|
||||
content={(message.content as string).replace(
|
||||
/\n/g,
|
||||
" ",
|
||||
)}
|
||||
/>
|
||||
</ReplyBase>
|
||||
<span class="actions">
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
setReplies(
|
||||
replies.map((_, i) =>
|
||||
i === index
|
||||
? { ..._, mention: !_.mention }
|
||||
: _,
|
||||
),
|
||||
)
|
||||
}>
|
||||
<span class="toggle">
|
||||
<At size={16} />{" "}
|
||||
{reply.mention ? "ON" : "OFF"}
|
||||
</span>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
setReplies(
|
||||
replies.filter((_, i) => i !== index),
|
||||
)
|
||||
}>
|
||||
<XCircle size={16} />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Base>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Base key={reply.id}>
|
||||
<ReplyBase preview>
|
||||
<ReplyIcon size={22} />
|
||||
<UserShort user={user} size={16} />
|
||||
{message.attachments &&
|
||||
message.attachments.length > 0 && (
|
||||
<File size={16} />
|
||||
)}
|
||||
<Markdown
|
||||
disallowBigEmoji
|
||||
content={(message.content as string).replace(
|
||||
/\n/g,
|
||||
" ",
|
||||
)}
|
||||
/>
|
||||
</ReplyBase>
|
||||
<span class="actions">
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
setReplies(
|
||||
replies.map((_, i) =>
|
||||
i === index
|
||||
? { ..._, mention: !_.mention }
|
||||
: _,
|
||||
),
|
||||
)
|
||||
}>
|
||||
<span class="toggle">
|
||||
<At size={16} />{" "}
|
||||
{reply.mention ? "ON" : "OFF"}
|
||||
</span>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
setReplies(
|
||||
replies.filter((_, i) => i !== index),
|
||||
)
|
||||
}>
|
||||
<XCircle size={16} />
|
||||
</IconButton>
|
||||
</span>
|
||||
</Base>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,111 +11,111 @@ import { AppContext } from "../../../../context/revoltjs/RevoltClient";
|
||||
import { useUsers } from "../../../../context/revoltjs/hooks";
|
||||
|
||||
interface Props {
|
||||
typing?: TypingUser[];
|
||||
typing?: TypingUser[];
|
||||
}
|
||||
|
||||
const Base = styled.div`
|
||||
position: relative;
|
||||
position: relative;
|
||||
|
||||
> div {
|
||||
height: 24px;
|
||||
margin-top: -24px;
|
||||
position: absolute;
|
||||
> div {
|
||||
height: 24px;
|
||||
margin-top: -24px;
|
||||
position: absolute;
|
||||
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
padding: 0 10px;
|
||||
user-select: none;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
width: calc(100% - 3px);
|
||||
color: var(--secondary-foreground);
|
||||
background: var(--secondary-background);
|
||||
}
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
padding: 0 10px;
|
||||
user-select: none;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
width: calc(100% - 3px);
|
||||
color: var(--secondary-foreground);
|
||||
background: var(--secondary-background);
|
||||
}
|
||||
|
||||
.avatars {
|
||||
display: flex;
|
||||
.avatars {
|
||||
display: flex;
|
||||
|
||||
img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
object-fit: cover;
|
||||
border-radius: 50%;
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-left: -4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
&:not(:first-child) {
|
||||
margin-left: -4px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.usernames {
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.usernames {
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
`;
|
||||
|
||||
export function TypingIndicator({ typing }: Props) {
|
||||
if (typing && typing.length > 0) {
|
||||
const client = useContext(AppContext);
|
||||
const users = useUsers(typing.map((x) => x.id)).filter(
|
||||
(x) => typeof x !== "undefined",
|
||||
) as User[];
|
||||
if (typing && typing.length > 0) {
|
||||
const client = useContext(AppContext);
|
||||
const users = useUsers(typing.map((x) => x.id)).filter(
|
||||
(x) => typeof x !== "undefined",
|
||||
) as User[];
|
||||
|
||||
users.sort((a, b) =>
|
||||
a._id.toUpperCase().localeCompare(b._id.toUpperCase()),
|
||||
);
|
||||
users.sort((a, b) =>
|
||||
a._id.toUpperCase().localeCompare(b._id.toUpperCase()),
|
||||
);
|
||||
|
||||
let text;
|
||||
if (users.length >= 5) {
|
||||
text = <Text id="app.main.channel.typing.several" />;
|
||||
} else if (users.length > 1) {
|
||||
const usersCopy = [...users];
|
||||
text = (
|
||||
<Text
|
||||
id="app.main.channel.typing.multiple"
|
||||
fields={{
|
||||
user: usersCopy.pop()?.username,
|
||||
userlist: usersCopy.map((x) => x.username).join(", "),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
text = (
|
||||
<Text
|
||||
id="app.main.channel.typing.single"
|
||||
fields={{ user: users[0].username }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
let text;
|
||||
if (users.length >= 5) {
|
||||
text = <Text id="app.main.channel.typing.several" />;
|
||||
} else if (users.length > 1) {
|
||||
const usersCopy = [...users];
|
||||
text = (
|
||||
<Text
|
||||
id="app.main.channel.typing.multiple"
|
||||
fields={{
|
||||
user: usersCopy.pop()?.username,
|
||||
userlist: usersCopy.map((x) => x.username).join(", "),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
text = (
|
||||
<Text
|
||||
id="app.main.channel.typing.single"
|
||||
fields={{ user: users[0].username }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Base>
|
||||
<div>
|
||||
<div className="avatars">
|
||||
{users.map((user) => (
|
||||
<img
|
||||
src={client.users.getAvatarURL(
|
||||
user._id,
|
||||
{ max_side: 256 },
|
||||
true,
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="usernames">{text}</div>
|
||||
</div>
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Base>
|
||||
<div>
|
||||
<div className="avatars">
|
||||
{users.map((user) => (
|
||||
<img
|
||||
src={client.users.getAvatarURL(
|
||||
user._id,
|
||||
{ max_side: 256 },
|
||||
true,
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="usernames">{text}</div>
|
||||
</div>
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
export default connectState<{ id: string }>(TypingIndicator, (state, props) => {
|
||||
return {
|
||||
typing: state.typing && state.typing[props.id],
|
||||
};
|
||||
return {
|
||||
typing: state.typing && state.typing[props.id],
|
||||
};
|
||||
});
|
||||
|
||||
@@ -10,7 +10,7 @@ import { MessageAreaWidthContext } from "../../../../pages/channels/messaging/Me
|
||||
import EmbedMedia from "./EmbedMedia";
|
||||
|
||||
interface Props {
|
||||
embed: EmbedRJS;
|
||||
embed: EmbedRJS;
|
||||
}
|
||||
|
||||
const MAX_EMBED_WIDTH = 480;
|
||||
@@ -19,149 +19,149 @@ const CONTAINER_PADDING = 24;
|
||||
const MAX_PREVIEW_SIZE = 150;
|
||||
|
||||
export default function Embed({ embed }: Props) {
|
||||
// ! FIXME: temp code
|
||||
// ! add proxy function to client
|
||||
function proxyImage(url: string) {
|
||||
return "https://jan.revolt.chat/proxy?url=" + encodeURIComponent(url);
|
||||
}
|
||||
// ! FIXME: temp code
|
||||
// ! add proxy function to client
|
||||
function proxyImage(url: string) {
|
||||
return "https://jan.revolt.chat/proxy?url=" + encodeURIComponent(url);
|
||||
}
|
||||
|
||||
const { openScreen } = useIntermediate();
|
||||
const maxWidth = Math.min(
|
||||
useContext(MessageAreaWidthContext) - CONTAINER_PADDING,
|
||||
MAX_EMBED_WIDTH,
|
||||
);
|
||||
const { openScreen } = useIntermediate();
|
||||
const maxWidth = Math.min(
|
||||
useContext(MessageAreaWidthContext) - CONTAINER_PADDING,
|
||||
MAX_EMBED_WIDTH,
|
||||
);
|
||||
|
||||
function calculateSize(
|
||||
w: number,
|
||||
h: number,
|
||||
): { width: number; height: number } {
|
||||
let limitingWidth = Math.min(maxWidth, w);
|
||||
function calculateSize(
|
||||
w: number,
|
||||
h: number,
|
||||
): { width: number; height: number } {
|
||||
let limitingWidth = Math.min(maxWidth, w);
|
||||
|
||||
let limitingHeight = Math.min(MAX_EMBED_HEIGHT, h);
|
||||
let limitingHeight = Math.min(MAX_EMBED_HEIGHT, h);
|
||||
|
||||
// Calculate smallest possible WxH.
|
||||
let width = Math.min(limitingWidth, limitingHeight * (w / h));
|
||||
// Calculate smallest possible WxH.
|
||||
let width = Math.min(limitingWidth, limitingHeight * (w / h));
|
||||
|
||||
let height = Math.min(limitingHeight, limitingWidth * (h / w));
|
||||
let height = Math.min(limitingHeight, limitingWidth * (h / w));
|
||||
|
||||
return { width, height };
|
||||
}
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
switch (embed.type) {
|
||||
case "Website": {
|
||||
// Determine special embed size.
|
||||
let mw, mh;
|
||||
let largeMedia =
|
||||
(embed.special && embed.special.type !== "None") ||
|
||||
embed.image?.size === "Large";
|
||||
switch (embed.special?.type) {
|
||||
case "YouTube":
|
||||
case "Bandcamp": {
|
||||
mw = embed.video?.width ?? 1280;
|
||||
mh = embed.video?.height ?? 720;
|
||||
break;
|
||||
}
|
||||
case "Twitch": {
|
||||
mw = 1280;
|
||||
mh = 720;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
if (embed.image?.size === "Preview") {
|
||||
mw = MAX_EMBED_WIDTH;
|
||||
mh = Math.min(
|
||||
embed.image.height ?? 0,
|
||||
MAX_PREVIEW_SIZE,
|
||||
);
|
||||
} else {
|
||||
mw = embed.image?.width ?? MAX_EMBED_WIDTH;
|
||||
mh = embed.image?.height ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
switch (embed.type) {
|
||||
case "Website": {
|
||||
// Determine special embed size.
|
||||
let mw, mh;
|
||||
let largeMedia =
|
||||
(embed.special && embed.special.type !== "None") ||
|
||||
embed.image?.size === "Large";
|
||||
switch (embed.special?.type) {
|
||||
case "YouTube":
|
||||
case "Bandcamp": {
|
||||
mw = embed.video?.width ?? 1280;
|
||||
mh = embed.video?.height ?? 720;
|
||||
break;
|
||||
}
|
||||
case "Twitch": {
|
||||
mw = 1280;
|
||||
mh = 720;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
if (embed.image?.size === "Preview") {
|
||||
mw = MAX_EMBED_WIDTH;
|
||||
mh = Math.min(
|
||||
embed.image.height ?? 0,
|
||||
MAX_PREVIEW_SIZE,
|
||||
);
|
||||
} else {
|
||||
mw = embed.image?.width ?? MAX_EMBED_WIDTH;
|
||||
mh = embed.image?.height ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let { width, height } = calculateSize(mw, mh);
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.embed, styles.website)}
|
||||
style={{
|
||||
borderInlineStartColor:
|
||||
embed.color ?? "var(--tertiary-background)",
|
||||
width: width + CONTAINER_PADDING,
|
||||
}}>
|
||||
<div>
|
||||
{embed.site_name && (
|
||||
<div className={styles.siteinfo}>
|
||||
{embed.icon_url && (
|
||||
<img
|
||||
className={styles.favicon}
|
||||
src={proxyImage(embed.icon_url)}
|
||||
draggable={false}
|
||||
onError={(e) =>
|
||||
(e.currentTarget.style.display =
|
||||
"none")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.site}>
|
||||
{embed.site_name}{" "}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
let { width, height } = calculateSize(mw, mh);
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.embed, styles.website)}
|
||||
style={{
|
||||
borderInlineStartColor:
|
||||
embed.color ?? "var(--tertiary-background)",
|
||||
width: width + CONTAINER_PADDING,
|
||||
}}>
|
||||
<div>
|
||||
{embed.site_name && (
|
||||
<div className={styles.siteinfo}>
|
||||
{embed.icon_url && (
|
||||
<img
|
||||
className={styles.favicon}
|
||||
src={proxyImage(embed.icon_url)}
|
||||
draggable={false}
|
||||
onError={(e) =>
|
||||
(e.currentTarget.style.display =
|
||||
"none")
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<div className={styles.site}>
|
||||
{embed.site_name}{" "}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/*<span><a href={embed.url} target={"_blank"} className={styles.author}>Author</a></span>*/}
|
||||
{embed.title && (
|
||||
<span>
|
||||
<a
|
||||
href={embed.url}
|
||||
target={"_blank"}
|
||||
className={styles.title}>
|
||||
{embed.title}
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
{embed.description && (
|
||||
<div className={styles.description}>
|
||||
{embed.description}
|
||||
</div>
|
||||
)}
|
||||
{/*<span><a href={embed.url} target={"_blank"} className={styles.author}>Author</a></span>*/}
|
||||
{embed.title && (
|
||||
<span>
|
||||
<a
|
||||
href={embed.url}
|
||||
target={"_blank"}
|
||||
className={styles.title}>
|
||||
{embed.title}
|
||||
</a>
|
||||
</span>
|
||||
)}
|
||||
{embed.description && (
|
||||
<div className={styles.description}>
|
||||
{embed.description}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{largeMedia && (
|
||||
<EmbedMedia embed={embed} height={height} />
|
||||
)}
|
||||
</div>
|
||||
{!largeMedia && (
|
||||
<div>
|
||||
<EmbedMedia
|
||||
embed={embed}
|
||||
width={
|
||||
height *
|
||||
((embed.image?.width ?? 0) /
|
||||
(embed.image?.height ?? 0))
|
||||
}
|
||||
height={height}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "Image": {
|
||||
return (
|
||||
<img
|
||||
className={classNames(styles.embed, styles.image)}
|
||||
style={calculateSize(embed.width, embed.height)}
|
||||
src={proxyImage(embed.url)}
|
||||
type="text/html"
|
||||
frameBorder="0"
|
||||
onClick={() => openScreen({ id: "image_viewer", embed })}
|
||||
onMouseDown={(ev) =>
|
||||
ev.button === 1 && window.open(embed.url, "_blank")
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
{largeMedia && (
|
||||
<EmbedMedia embed={embed} height={height} />
|
||||
)}
|
||||
</div>
|
||||
{!largeMedia && (
|
||||
<div>
|
||||
<EmbedMedia
|
||||
embed={embed}
|
||||
width={
|
||||
height *
|
||||
((embed.image?.width ?? 0) /
|
||||
(embed.image?.height ?? 0))
|
||||
}
|
||||
height={height}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
case "Image": {
|
||||
return (
|
||||
<img
|
||||
className={classNames(styles.embed, styles.image)}
|
||||
style={calculateSize(embed.width, embed.height)}
|
||||
src={proxyImage(embed.url)}
|
||||
type="text/html"
|
||||
frameBorder="0"
|
||||
onClick={() => openScreen({ id: "image_viewer", embed })}
|
||||
onMouseDown={(ev) =>
|
||||
ev.button === 1 && window.open(embed.url, "_blank")
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,96 +5,96 @@ import styles from "./Embed.module.scss";
|
||||
import { useIntermediate } from "../../../../context/intermediate/Intermediate";
|
||||
|
||||
interface Props {
|
||||
embed: Embed;
|
||||
width?: number;
|
||||
height: number;
|
||||
embed: Embed;
|
||||
width?: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export default function EmbedMedia({ embed, width, height }: Props) {
|
||||
// ! FIXME: temp code
|
||||
// ! add proxy function to client
|
||||
function proxyImage(url: string) {
|
||||
return "https://jan.revolt.chat/proxy?url=" + encodeURIComponent(url);
|
||||
}
|
||||
// ! FIXME: temp code
|
||||
// ! add proxy function to client
|
||||
function proxyImage(url: string) {
|
||||
return "https://jan.revolt.chat/proxy?url=" + encodeURIComponent(url);
|
||||
}
|
||||
|
||||
if (embed.type !== "Website") return null;
|
||||
const { openScreen } = useIntermediate();
|
||||
if (embed.type !== "Website") return null;
|
||||
const { openScreen } = useIntermediate();
|
||||
|
||||
switch (embed.special?.type) {
|
||||
case "YouTube":
|
||||
return (
|
||||
<iframe
|
||||
src={`https://www.youtube-nocookie.com/embed/${embed.special.id}?modestbranding=1`}
|
||||
allowFullScreen
|
||||
style={{ height }}
|
||||
/>
|
||||
);
|
||||
case "Twitch":
|
||||
return (
|
||||
<iframe
|
||||
src={`https://player.twitch.tv/?${embed.special.content_type.toLowerCase()}=${
|
||||
embed.special.id
|
||||
}&parent=${window.location.hostname}&autoplay=false`}
|
||||
frameBorder="0"
|
||||
allowFullScreen
|
||||
scrolling="no"
|
||||
style={{ height }}
|
||||
/>
|
||||
);
|
||||
case "Spotify":
|
||||
return (
|
||||
<iframe
|
||||
src={`https://open.spotify.com/embed/${embed.special.content_type}/${embed.special.id}`}
|
||||
frameBorder="0"
|
||||
allowFullScreen
|
||||
allowTransparency
|
||||
style={{ height }}
|
||||
/>
|
||||
);
|
||||
case "Soundcloud":
|
||||
return (
|
||||
<iframe
|
||||
src={`https://w.soundcloud.com/player/?url=${encodeURIComponent(
|
||||
embed.url!,
|
||||
)}&color=%23FF7F50&auto_play=false&hide_related=false&show_comments=true&show_user=true&show_reposts=false&show_teaser=true&visual=true`}
|
||||
frameBorder="0"
|
||||
scrolling="no"
|
||||
style={{ height }}
|
||||
/>
|
||||
);
|
||||
case "Bandcamp": {
|
||||
return (
|
||||
<iframe
|
||||
src={`https://bandcamp.com/EmbeddedPlayer/${embed.special.content_type.toLowerCase()}=${
|
||||
embed.special.id
|
||||
}/size=large/bgcol=181a1b/linkcol=056cc4/tracklist=false/transparent=true/`}
|
||||
seamless
|
||||
style={{ height }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default: {
|
||||
if (embed.image) {
|
||||
let url = embed.image.url;
|
||||
return (
|
||||
<img
|
||||
className={styles.image}
|
||||
src={proxyImage(url)}
|
||||
style={{ width, height }}
|
||||
onClick={() =>
|
||||
openScreen({
|
||||
id: "image_viewer",
|
||||
embed: embed.image,
|
||||
})
|
||||
}
|
||||
onMouseDown={(ev) =>
|
||||
ev.button === 1 && window.open(url, "_blank")
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
switch (embed.special?.type) {
|
||||
case "YouTube":
|
||||
return (
|
||||
<iframe
|
||||
src={`https://www.youtube-nocookie.com/embed/${embed.special.id}?modestbranding=1`}
|
||||
allowFullScreen
|
||||
style={{ height }}
|
||||
/>
|
||||
);
|
||||
case "Twitch":
|
||||
return (
|
||||
<iframe
|
||||
src={`https://player.twitch.tv/?${embed.special.content_type.toLowerCase()}=${
|
||||
embed.special.id
|
||||
}&parent=${window.location.hostname}&autoplay=false`}
|
||||
frameBorder="0"
|
||||
allowFullScreen
|
||||
scrolling="no"
|
||||
style={{ height }}
|
||||
/>
|
||||
);
|
||||
case "Spotify":
|
||||
return (
|
||||
<iframe
|
||||
src={`https://open.spotify.com/embed/${embed.special.content_type}/${embed.special.id}`}
|
||||
frameBorder="0"
|
||||
allowFullScreen
|
||||
allowTransparency
|
||||
style={{ height }}
|
||||
/>
|
||||
);
|
||||
case "Soundcloud":
|
||||
return (
|
||||
<iframe
|
||||
src={`https://w.soundcloud.com/player/?url=${encodeURIComponent(
|
||||
embed.url!,
|
||||
)}&color=%23FF7F50&auto_play=false&hide_related=false&show_comments=true&show_user=true&show_reposts=false&show_teaser=true&visual=true`}
|
||||
frameBorder="0"
|
||||
scrolling="no"
|
||||
style={{ height }}
|
||||
/>
|
||||
);
|
||||
case "Bandcamp": {
|
||||
return (
|
||||
<iframe
|
||||
src={`https://bandcamp.com/EmbeddedPlayer/${embed.special.content_type.toLowerCase()}=${
|
||||
embed.special.id
|
||||
}/size=large/bgcol=181a1b/linkcol=056cc4/tracklist=false/transparent=true/`}
|
||||
seamless
|
||||
style={{ height }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
default: {
|
||||
if (embed.image) {
|
||||
let url = embed.image.url;
|
||||
return (
|
||||
<img
|
||||
className={styles.image}
|
||||
src={proxyImage(url)}
|
||||
style={{ width, height }}
|
||||
onClick={() =>
|
||||
openScreen({
|
||||
id: "image_viewer",
|
||||
embed: embed.image,
|
||||
})
|
||||
}
|
||||
onMouseDown={(ev) =>
|
||||
ev.button === 1 && window.open(url, "_blank")
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -6,25 +6,25 @@ import styles from "./Embed.module.scss";
|
||||
import IconButton from "../../../ui/IconButton";
|
||||
|
||||
interface Props {
|
||||
embed: EmbedImage;
|
||||
embed: EmbedImage;
|
||||
}
|
||||
|
||||
export default function EmbedMediaActions({ embed }: Props) {
|
||||
const filename = embed.url.split("/").pop();
|
||||
const filename = embed.url.split("/").pop();
|
||||
|
||||
return (
|
||||
<div className={styles.actions}>
|
||||
<div className={styles.info}>
|
||||
<span className={styles.filename}>{filename}</span>
|
||||
<span className={styles.filesize}>
|
||||
{embed.width + "x" + embed.height}
|
||||
</span>
|
||||
</div>
|
||||
<a href={embed.url} target="_blank">
|
||||
<IconButton>
|
||||
<LinkExternal size={24} />
|
||||
</IconButton>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className={styles.actions}>
|
||||
<div className={styles.info}>
|
||||
<span className={styles.filename}>{filename}</span>
|
||||
<span className={styles.filesize}>
|
||||
{embed.width + "x" + embed.height}
|
||||
</span>
|
||||
</div>
|
||||
<a href={embed.url} target="_blank">
|
||||
<IconButton>
|
||||
<LinkExternal size={24} />
|
||||
</IconButton>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ import UserIcon from "./UserIcon";
|
||||
type UserProps = Omit<CheckboxProps, "children"> & { user: User };
|
||||
|
||||
export default function UserCheckbox({ user, ...props }: UserProps) {
|
||||
return (
|
||||
<Checkbox {...props}>
|
||||
<UserIcon target={user} size={32} />
|
||||
{user.username}
|
||||
</Checkbox>
|
||||
);
|
||||
return (
|
||||
<Checkbox {...props}>
|
||||
<UserIcon target={user} size={32} />
|
||||
{user.username}
|
||||
</Checkbox>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,66 +19,66 @@ import UserIcon from "./UserIcon";
|
||||
import UserStatus from "./UserStatus";
|
||||
|
||||
const HeaderBase = styled.div`
|
||||
gap: 0;
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
flex-grow: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
* {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
* {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.username {
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.username {
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status {
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.status {
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
margin-top: -2px;
|
||||
}
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export default function UserHeader({ user }: Props) {
|
||||
const { writeClipboard } = useIntermediate();
|
||||
const { writeClipboard } = useIntermediate();
|
||||
|
||||
return (
|
||||
<Header borders placement="secondary">
|
||||
<HeaderBase>
|
||||
<Localizer>
|
||||
<Tooltip content={<Text id="app.special.copy_username" />}>
|
||||
<span
|
||||
className="username"
|
||||
onClick={() => writeClipboard(user.username)}>
|
||||
@{user.username}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Localizer>
|
||||
<span
|
||||
className="status"
|
||||
onClick={() => openContextMenu("Status")}>
|
||||
<UserStatus user={user} />
|
||||
</span>
|
||||
</HeaderBase>
|
||||
{!isTouchscreenDevice && (
|
||||
<div className="actions">
|
||||
<Link to="/settings">
|
||||
<IconButton>
|
||||
<Cog size={24} />
|
||||
</IconButton>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</Header>
|
||||
);
|
||||
return (
|
||||
<Header borders placement="secondary">
|
||||
<HeaderBase>
|
||||
<Localizer>
|
||||
<Tooltip content={<Text id="app.special.copy_username" />}>
|
||||
<span
|
||||
className="username"
|
||||
onClick={() => writeClipboard(user.username)}>
|
||||
@{user.username}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Localizer>
|
||||
<span
|
||||
className="status"
|
||||
onClick={() => openContextMenu("Status")}>
|
||||
<UserStatus user={user} />
|
||||
</span>
|
||||
</HeaderBase>
|
||||
{!isTouchscreenDevice && (
|
||||
<div className="actions">
|
||||
<Link to="/settings">
|
||||
<IconButton>
|
||||
<Cog size={24} />
|
||||
</IconButton>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</Header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,92 +13,92 @@ import fallback from "../assets/user.png";
|
||||
|
||||
type VoiceStatus = "muted";
|
||||
interface Props extends IconBaseProps<User> {
|
||||
mask?: string;
|
||||
status?: boolean;
|
||||
voice?: VoiceStatus;
|
||||
mask?: string;
|
||||
status?: boolean;
|
||||
voice?: VoiceStatus;
|
||||
}
|
||||
|
||||
export function useStatusColour(user?: User) {
|
||||
const theme = useContext(ThemeContext);
|
||||
const theme = useContext(ThemeContext);
|
||||
|
||||
return user?.online && user?.status?.presence !== Users.Presence.Invisible
|
||||
? user?.status?.presence === Users.Presence.Idle
|
||||
? theme["status-away"]
|
||||
: user?.status?.presence === Users.Presence.Busy
|
||||
? theme["status-busy"]
|
||||
: theme["status-online"]
|
||||
: theme["status-invisible"];
|
||||
return user?.online && user?.status?.presence !== Users.Presence.Invisible
|
||||
? user?.status?.presence === Users.Presence.Idle
|
||||
? theme["status-away"]
|
||||
: user?.status?.presence === Users.Presence.Busy
|
||||
? theme["status-busy"]
|
||||
: theme["status-online"]
|
||||
: theme["status-invisible"];
|
||||
}
|
||||
|
||||
const VoiceIndicator = styled.div<{ status: VoiceStatus }>`
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
stroke: white;
|
||||
}
|
||||
svg {
|
||||
stroke: white;
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.status === "muted" &&
|
||||
css`
|
||||
background: var(--error);
|
||||
`}
|
||||
${(props) =>
|
||||
props.status === "muted" &&
|
||||
css`
|
||||
background: var(--error);
|
||||
`}
|
||||
`;
|
||||
|
||||
export default function UserIcon(
|
||||
props: Props & Omit<JSX.SVGAttributes<SVGSVGElement>, keyof Props>,
|
||||
props: Props & Omit<JSX.SVGAttributes<SVGSVGElement>, keyof Props>,
|
||||
) {
|
||||
const client = useContext(AppContext);
|
||||
const client = useContext(AppContext);
|
||||
|
||||
const {
|
||||
target,
|
||||
attachment,
|
||||
size,
|
||||
voice,
|
||||
status,
|
||||
animate,
|
||||
mask,
|
||||
children,
|
||||
as,
|
||||
...svgProps
|
||||
} = props;
|
||||
const iconURL =
|
||||
client.generateFileURL(
|
||||
target?.avatar ?? attachment,
|
||||
{ max_side: 256 },
|
||||
animate,
|
||||
) ?? (target ? client.users.getDefaultAvatarURL(target._id) : fallback);
|
||||
const {
|
||||
target,
|
||||
attachment,
|
||||
size,
|
||||
voice,
|
||||
status,
|
||||
animate,
|
||||
mask,
|
||||
children,
|
||||
as,
|
||||
...svgProps
|
||||
} = props;
|
||||
const iconURL =
|
||||
client.generateFileURL(
|
||||
target?.avatar ?? attachment,
|
||||
{ max_side: 256 },
|
||||
animate,
|
||||
) ?? (target ? client.users.getDefaultAvatarURL(target._id) : fallback);
|
||||
|
||||
return (
|
||||
<IconBase
|
||||
{...svgProps}
|
||||
width={size}
|
||||
height={size}
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 32 32">
|
||||
<foreignObject
|
||||
x="0"
|
||||
y="0"
|
||||
width="32"
|
||||
height="32"
|
||||
mask={mask ?? (status ? "url(#user)" : undefined)}>
|
||||
{<img src={iconURL} draggable={false} />}
|
||||
</foreignObject>
|
||||
{props.status && (
|
||||
<circle cx="27" cy="27" r="5" fill={useStatusColour(target)} />
|
||||
)}
|
||||
{props.voice && (
|
||||
<foreignObject x="22" y="22" width="10" height="10">
|
||||
<VoiceIndicator status={props.voice}>
|
||||
{props.voice === "muted" && <MicrophoneOff size={6} />}
|
||||
</VoiceIndicator>
|
||||
</foreignObject>
|
||||
)}
|
||||
</IconBase>
|
||||
);
|
||||
return (
|
||||
<IconBase
|
||||
{...svgProps}
|
||||
width={size}
|
||||
height={size}
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 32 32">
|
||||
<foreignObject
|
||||
x="0"
|
||||
y="0"
|
||||
width="32"
|
||||
height="32"
|
||||
mask={mask ?? (status ? "url(#user)" : undefined)}>
|
||||
{<img src={iconURL} draggable={false} />}
|
||||
</foreignObject>
|
||||
{props.status && (
|
||||
<circle cx="27" cy="27" r="5" fill={useStatusColour(target)} />
|
||||
)}
|
||||
{props.voice && (
|
||||
<foreignObject x="22" y="22" width="10" height="10">
|
||||
<VoiceIndicator status={props.voice}>
|
||||
{props.voice === "muted" && <MicrophoneOff size={6} />}
|
||||
</VoiceIndicator>
|
||||
</foreignObject>
|
||||
)}
|
||||
</IconBase>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,27 +5,27 @@ import { Text } from "preact-i18n";
|
||||
import UserIcon from "./UserIcon";
|
||||
|
||||
export function Username({
|
||||
user,
|
||||
...otherProps
|
||||
user,
|
||||
...otherProps
|
||||
}: { user?: User } & JSX.HTMLAttributes<HTMLElement>) {
|
||||
return (
|
||||
<span {...otherProps}>
|
||||
{user?.username ?? <Text id="app.main.channel.unknown_user" />}
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span {...otherProps}>
|
||||
{user?.username ?? <Text id="app.main.channel.unknown_user" />}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function UserShort({
|
||||
user,
|
||||
size,
|
||||
user,
|
||||
size,
|
||||
}: {
|
||||
user?: User;
|
||||
size?: number;
|
||||
user?: User;
|
||||
size?: number;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<UserIcon size={size ?? 24} target={user} />
|
||||
<Username user={user} />
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<UserIcon size={size ?? 24} target={user} />
|
||||
<Username user={user} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,29 +4,29 @@ import { Users } from "revolt.js/dist/api/objects";
|
||||
import { Text } from "preact-i18n";
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export default function UserStatus({ user }: Props) {
|
||||
if (user.online) {
|
||||
if (user.status?.text) {
|
||||
return <>{user.status?.text}</>;
|
||||
}
|
||||
if (user.online) {
|
||||
if (user.status?.text) {
|
||||
return <>{user.status?.text}</>;
|
||||
}
|
||||
|
||||
if (user.status?.presence === Users.Presence.Busy) {
|
||||
return <Text id="app.status.busy" />;
|
||||
}
|
||||
if (user.status?.presence === Users.Presence.Busy) {
|
||||
return <Text id="app.status.busy" />;
|
||||
}
|
||||
|
||||
if (user.status?.presence === Users.Presence.Idle) {
|
||||
return <Text id="app.status.idle" />;
|
||||
}
|
||||
if (user.status?.presence === Users.Presence.Idle) {
|
||||
return <Text id="app.status.idle" />;
|
||||
}
|
||||
|
||||
if (user.status?.presence === Users.Presence.Invisible) {
|
||||
return <Text id="app.status.offline" />;
|
||||
}
|
||||
if (user.status?.presence === Users.Presence.Invisible) {
|
||||
return <Text id="app.status.offline" />;
|
||||
}
|
||||
|
||||
return <Text id="app.status.online" />;
|
||||
}
|
||||
return <Text id="app.status.online" />;
|
||||
}
|
||||
|
||||
return <Text id="app.status.offline" />;
|
||||
return <Text id="app.status.offline" />;
|
||||
}
|
||||
|
||||
@@ -3,15 +3,15 @@ import { Suspense, lazy } from "preact/compat";
|
||||
const Renderer = lazy(() => import("./Renderer"));
|
||||
|
||||
export interface MarkdownProps {
|
||||
content?: string;
|
||||
disallowBigEmoji?: boolean;
|
||||
content?: string;
|
||||
disallowBigEmoji?: boolean;
|
||||
}
|
||||
|
||||
export default function Markdown(props: MarkdownProps) {
|
||||
return (
|
||||
// @ts-expect-error
|
||||
<Suspense fallback={props.content}>
|
||||
<Renderer {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
return (
|
||||
// @ts-expect-error
|
||||
<Suspense fallback={props.content}>
|
||||
<Renderer {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,167 +26,167 @@ import { MarkdownProps } from "./Markdown";
|
||||
|
||||
// TODO: global.d.ts file for defining globals
|
||||
declare global {
|
||||
interface Window {
|
||||
copycode: (element: HTMLDivElement) => void;
|
||||
}
|
||||
interface Window {
|
||||
copycode: (element: HTMLDivElement) => void;
|
||||
}
|
||||
}
|
||||
|
||||
// Handler for code block copy.
|
||||
if (typeof window !== "undefined") {
|
||||
window.copycode = function (element: HTMLDivElement) {
|
||||
try {
|
||||
let code = element.parentElement?.parentElement?.children[1];
|
||||
if (code) {
|
||||
navigator.clipboard.writeText(code.textContent?.trim() ?? "");
|
||||
}
|
||||
} catch (e) {}
|
||||
};
|
||||
window.copycode = function (element: HTMLDivElement) {
|
||||
try {
|
||||
let code = element.parentElement?.parentElement?.children[1];
|
||||
if (code) {
|
||||
navigator.clipboard.writeText(code.textContent?.trim() ?? "");
|
||||
}
|
||||
} catch (e) {}
|
||||
};
|
||||
}
|
||||
|
||||
export const md: MarkdownIt = MarkdownIt({
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
highlight: (str, lang) => {
|
||||
let v = Prism.languages[lang];
|
||||
if (v) {
|
||||
let out = Prism.highlight(str, v, lang);
|
||||
return `<pre class="code"><div class="lang"><div onclick="copycode(this)">${lang}</div></div><code class="language-${lang}">${out}</code></pre>`;
|
||||
}
|
||||
breaks: true,
|
||||
linkify: true,
|
||||
highlight: (str, lang) => {
|
||||
let v = Prism.languages[lang];
|
||||
if (v) {
|
||||
let out = Prism.highlight(str, v, lang);
|
||||
return `<pre class="code"><div class="lang"><div onclick="copycode(this)">${lang}</div></div><code class="language-${lang}">${out}</code></pre>`;
|
||||
}
|
||||
|
||||
return `<pre class="code"><code>${md.utils.escapeHtml(
|
||||
str,
|
||||
)}</code></pre>`;
|
||||
},
|
||||
return `<pre class="code"><code>${md.utils.escapeHtml(
|
||||
str,
|
||||
)}</code></pre>`;
|
||||
},
|
||||
})
|
||||
.disable("image")
|
||||
.use(MarkdownEmoji, { defs: emojiDictionary })
|
||||
.use(MarkdownSpoilers)
|
||||
.use(MarkdownSup)
|
||||
.use(MarkdownSub)
|
||||
.use(MarkdownKatex, {
|
||||
throwOnError: false,
|
||||
maxExpand: 0,
|
||||
});
|
||||
.disable("image")
|
||||
.use(MarkdownEmoji, { defs: emojiDictionary })
|
||||
.use(MarkdownSpoilers)
|
||||
.use(MarkdownSup)
|
||||
.use(MarkdownSub)
|
||||
.use(MarkdownKatex, {
|
||||
throwOnError: false,
|
||||
maxExpand: 0,
|
||||
});
|
||||
|
||||
// ? Force links to open _blank.
|
||||
// From: https://github.com/markdown-it/markdown-it/blob/master/docs/architecture.md#renderer
|
||||
const defaultRender =
|
||||
md.renderer.rules.link_open ||
|
||||
function (tokens, idx, options, _env, self) {
|
||||
return self.renderToken(tokens, idx, options);
|
||||
};
|
||||
md.renderer.rules.link_open ||
|
||||
function (tokens, idx, options, _env, self) {
|
||||
return self.renderToken(tokens, idx, options);
|
||||
};
|
||||
|
||||
// TODO: global.d.ts file for defining globals
|
||||
declare global {
|
||||
interface Window {
|
||||
internalHandleURL: (element: HTMLAnchorElement) => void;
|
||||
}
|
||||
interface Window {
|
||||
internalHandleURL: (element: HTMLAnchorElement) => void;
|
||||
}
|
||||
}
|
||||
|
||||
// Handler for internal links, pushes events to React using magic.
|
||||
if (typeof window !== "undefined") {
|
||||
window.internalHandleURL = function (element: HTMLAnchorElement) {
|
||||
const url = new URL(element.href, location.href);
|
||||
const pathname = url.pathname;
|
||||
window.internalHandleURL = function (element: HTMLAnchorElement) {
|
||||
const url = new URL(element.href, location.href);
|
||||
const pathname = url.pathname;
|
||||
|
||||
if (pathname.startsWith("/@")) {
|
||||
internalEmit("Intermediate", "openProfile", pathname.substr(2));
|
||||
} else {
|
||||
internalEmit("Intermediate", "navigate", pathname);
|
||||
}
|
||||
};
|
||||
if (pathname.startsWith("/@")) {
|
||||
internalEmit("Intermediate", "openProfile", pathname.substr(2));
|
||||
} else {
|
||||
internalEmit("Intermediate", "navigate", pathname);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
|
||||
let internal;
|
||||
const hIndex = tokens[idx].attrIndex("href");
|
||||
if (hIndex >= 0) {
|
||||
try {
|
||||
// For internal links, we should use our own handler to use react-router history.
|
||||
// @ts-ignore
|
||||
const href = tokens[idx].attrs[hIndex][1];
|
||||
const url = new URL(href, location.href);
|
||||
let internal;
|
||||
const hIndex = tokens[idx].attrIndex("href");
|
||||
if (hIndex >= 0) {
|
||||
try {
|
||||
// For internal links, we should use our own handler to use react-router history.
|
||||
// @ts-ignore
|
||||
const href = tokens[idx].attrs[hIndex][1];
|
||||
const url = new URL(href, location.href);
|
||||
|
||||
if (url.hostname === location.hostname) {
|
||||
internal = true;
|
||||
// I'm sorry.
|
||||
tokens[idx].attrPush([
|
||||
"onclick",
|
||||
"internalHandleURL(this); return false",
|
||||
]);
|
||||
if (url.hostname === location.hostname) {
|
||||
internal = true;
|
||||
// I'm sorry.
|
||||
tokens[idx].attrPush([
|
||||
"onclick",
|
||||
"internalHandleURL(this); return false",
|
||||
]);
|
||||
|
||||
if (url.pathname.startsWith("/@")) {
|
||||
tokens[idx].attrPush(["data-type", "mention"]);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore the error, treat as normal link.
|
||||
}
|
||||
}
|
||||
if (url.pathname.startsWith("/@")) {
|
||||
tokens[idx].attrPush(["data-type", "mention"]);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore the error, treat as normal link.
|
||||
}
|
||||
}
|
||||
|
||||
if (!internal) {
|
||||
// Add target=_blank for external links.
|
||||
const aIndex = tokens[idx].attrIndex("target");
|
||||
if (!internal) {
|
||||
// Add target=_blank for external links.
|
||||
const aIndex = tokens[idx].attrIndex("target");
|
||||
|
||||
if (aIndex < 0) {
|
||||
tokens[idx].attrPush(["target", "_blank"]);
|
||||
} else {
|
||||
try {
|
||||
// @ts-ignore
|
||||
tokens[idx].attrs[aIndex][1] = "_blank";
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
if (aIndex < 0) {
|
||||
tokens[idx].attrPush(["target", "_blank"]);
|
||||
} else {
|
||||
try {
|
||||
// @ts-ignore
|
||||
tokens[idx].attrs[aIndex][1] = "_blank";
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
return defaultRender(tokens, idx, options, env, self);
|
||||
return defaultRender(tokens, idx, options, env, self);
|
||||
};
|
||||
|
||||
md.renderer.rules.emoji = function (token, idx) {
|
||||
return generateEmoji(token[idx].content);
|
||||
return generateEmoji(token[idx].content);
|
||||
};
|
||||
|
||||
const RE_TWEMOJI = /:(\w+):/g;
|
||||
|
||||
export default function Renderer({ content, disallowBigEmoji }: MarkdownProps) {
|
||||
const client = useContext(AppContext);
|
||||
if (typeof content === "undefined") return null;
|
||||
if (content.length === 0) return null;
|
||||
const client = useContext(AppContext);
|
||||
if (typeof content === "undefined") return null;
|
||||
if (content.length === 0) return null;
|
||||
|
||||
// We replace the message with the mention at the time of render.
|
||||
// We don't care if the mention changes.
|
||||
let newContent = content.replace(
|
||||
RE_MENTIONS,
|
||||
(sub: string, ...args: any[]) => {
|
||||
const id = args[0],
|
||||
user = client.users.get(id);
|
||||
// We replace the message with the mention at the time of render.
|
||||
// We don't care if the mention changes.
|
||||
let newContent = content.replace(
|
||||
RE_MENTIONS,
|
||||
(sub: string, ...args: any[]) => {
|
||||
const id = args[0],
|
||||
user = client.users.get(id);
|
||||
|
||||
if (user) {
|
||||
return `[@${user.username}](/@${id})`;
|
||||
}
|
||||
if (user) {
|
||||
return `[@${user.username}](/@${id})`;
|
||||
}
|
||||
|
||||
return sub;
|
||||
},
|
||||
);
|
||||
return sub;
|
||||
},
|
||||
);
|
||||
|
||||
const useLargeEmojis = disallowBigEmoji
|
||||
? false
|
||||
: content.replace(RE_TWEMOJI, "").trim().length === 0;
|
||||
const useLargeEmojis = disallowBigEmoji
|
||||
? false
|
||||
: content.replace(RE_TWEMOJI, "").trim().length === 0;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={styles.markdown}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: md.render(newContent),
|
||||
}}
|
||||
data-large-emojis={useLargeEmojis}
|
||||
onClick={(ev) => {
|
||||
if (ev.target) {
|
||||
let element = ev.currentTarget;
|
||||
if (element.classList.contains("spoiler")) {
|
||||
element.classList.add("shown");
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<span
|
||||
className={styles.markdown}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: md.render(newContent),
|
||||
}}
|
||||
data-large-emojis={useLargeEmojis}
|
||||
onClick={(ev) => {
|
||||
if (ev.target) {
|
||||
let element = ev.currentTarget;
|
||||
if (element.classList.contains("spoiler")) {
|
||||
element.classList.add("shown");
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,84 +13,84 @@ import UserIcon from "../common/user/UserIcon";
|
||||
import IconButton from "../ui/IconButton";
|
||||
|
||||
const NavigationBase = styled.div`
|
||||
z-index: 100;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
background: var(--secondary-background);
|
||||
z-index: 100;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
background: var(--secondary-background);
|
||||
`;
|
||||
|
||||
const Button = styled.a<{ active: boolean }>`
|
||||
flex: 1;
|
||||
flex: 1;
|
||||
|
||||
> a,
|
||||
> div,
|
||||
> a > div {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
> a,
|
||||
> div,
|
||||
> a > div {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.active &&
|
||||
css`
|
||||
background: var(--hover);
|
||||
`}
|
||||
${(props) =>
|
||||
props.active &&
|
||||
css`
|
||||
background: var(--hover);
|
||||
`}
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
lastOpened: LastOpened;
|
||||
lastOpened: LastOpened;
|
||||
}
|
||||
|
||||
export function BottomNavigation({ lastOpened }: Props) {
|
||||
const user = useSelf();
|
||||
const history = useHistory();
|
||||
const path = useLocation().pathname;
|
||||
const user = useSelf();
|
||||
const history = useHistory();
|
||||
const path = useLocation().pathname;
|
||||
|
||||
const channel_id = lastOpened["home"];
|
||||
const channel_id = lastOpened["home"];
|
||||
|
||||
const friendsActive = path.startsWith("/friends");
|
||||
const settingsActive = path.startsWith("/settings");
|
||||
const homeActive = !(friendsActive || settingsActive);
|
||||
const friendsActive = path.startsWith("/friends");
|
||||
const settingsActive = path.startsWith("/settings");
|
||||
const homeActive = !(friendsActive || settingsActive);
|
||||
|
||||
return (
|
||||
<NavigationBase>
|
||||
<Button active={homeActive}>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
if (settingsActive) {
|
||||
if (history.length > 0) {
|
||||
history.goBack();
|
||||
}
|
||||
}
|
||||
return (
|
||||
<NavigationBase>
|
||||
<Button active={homeActive}>
|
||||
<IconButton
|
||||
onClick={() => {
|
||||
if (settingsActive) {
|
||||
if (history.length > 0) {
|
||||
history.goBack();
|
||||
}
|
||||
}
|
||||
|
||||
if (channel_id) {
|
||||
history.push(`/channel/${channel_id}`);
|
||||
} else {
|
||||
history.push("/");
|
||||
}
|
||||
}}>
|
||||
<Message size={24} />
|
||||
</IconButton>
|
||||
</Button>
|
||||
<Button active={friendsActive}>
|
||||
<ConditionalLink active={friendsActive} to="/friends">
|
||||
<IconButton>
|
||||
<Group size={25} />
|
||||
</IconButton>
|
||||
</ConditionalLink>
|
||||
</Button>
|
||||
<Button active={settingsActive}>
|
||||
<ConditionalLink active={settingsActive} to="/settings">
|
||||
<IconButton>
|
||||
<UserIcon target={user} size={26} status={true} />
|
||||
</IconButton>
|
||||
</ConditionalLink>
|
||||
</Button>
|
||||
</NavigationBase>
|
||||
);
|
||||
if (channel_id) {
|
||||
history.push(`/channel/${channel_id}`);
|
||||
} else {
|
||||
history.push("/");
|
||||
}
|
||||
}}>
|
||||
<Message size={24} />
|
||||
</IconButton>
|
||||
</Button>
|
||||
<Button active={friendsActive}>
|
||||
<ConditionalLink active={friendsActive} to="/friends">
|
||||
<IconButton>
|
||||
<Group size={25} />
|
||||
</IconButton>
|
||||
</ConditionalLink>
|
||||
</Button>
|
||||
<Button active={settingsActive}>
|
||||
<ConditionalLink active={settingsActive} to="/settings">
|
||||
<IconButton>
|
||||
<UserIcon target={user} size={26} status={true} />
|
||||
</IconButton>
|
||||
</ConditionalLink>
|
||||
</Button>
|
||||
</NavigationBase>
|
||||
);
|
||||
}
|
||||
|
||||
export default connectState(BottomNavigation, (state) => {
|
||||
return {
|
||||
lastOpened: state.lastOpened,
|
||||
};
|
||||
return {
|
||||
lastOpened: state.lastOpened,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -6,27 +6,27 @@ import ServerListSidebar from "./left/ServerListSidebar";
|
||||
import ServerSidebar from "./left/ServerSidebar";
|
||||
|
||||
export default function LeftSidebar() {
|
||||
return (
|
||||
<SidebarBase>
|
||||
<Switch>
|
||||
<Route path="/settings" />
|
||||
<Route path="/server/:server/channel/:channel">
|
||||
<ServerListSidebar />
|
||||
<ServerSidebar />
|
||||
</Route>
|
||||
<Route path="/server/:server">
|
||||
<ServerListSidebar />
|
||||
<ServerSidebar />
|
||||
</Route>
|
||||
<Route path="/channel/:channel">
|
||||
<ServerListSidebar />
|
||||
<HomeSidebar />
|
||||
</Route>
|
||||
<Route path="/">
|
||||
<ServerListSidebar />
|
||||
<HomeSidebar />
|
||||
</Route>
|
||||
</Switch>
|
||||
</SidebarBase>
|
||||
);
|
||||
return (
|
||||
<SidebarBase>
|
||||
<Switch>
|
||||
<Route path="/settings" />
|
||||
<Route path="/server/:server/channel/:channel">
|
||||
<ServerListSidebar />
|
||||
<ServerSidebar />
|
||||
</Route>
|
||||
<Route path="/server/:server">
|
||||
<ServerListSidebar />
|
||||
<ServerSidebar />
|
||||
</Route>
|
||||
<Route path="/channel/:channel">
|
||||
<ServerListSidebar />
|
||||
<HomeSidebar />
|
||||
</Route>
|
||||
<Route path="/">
|
||||
<ServerListSidebar />
|
||||
<HomeSidebar />
|
||||
</Route>
|
||||
</Switch>
|
||||
</SidebarBase>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,16 +4,16 @@ import SidebarBase from "./SidebarBase";
|
||||
import MemberSidebar from "./right/MemberSidebar";
|
||||
|
||||
export default function RightSidebar() {
|
||||
return (
|
||||
<SidebarBase>
|
||||
<Switch>
|
||||
<Route path="/server/:server/channel/:channel">
|
||||
<MemberSidebar />
|
||||
</Route>
|
||||
<Route path="/channel/:channel">
|
||||
<MemberSidebar />
|
||||
</Route>
|
||||
</Switch>
|
||||
</SidebarBase>
|
||||
);
|
||||
return (
|
||||
<SidebarBase>
|
||||
<Switch>
|
||||
<Route path="/server/:server/channel/:channel">
|
||||
<MemberSidebar />
|
||||
</Route>
|
||||
<Route path="/channel/:channel">
|
||||
<MemberSidebar />
|
||||
</Route>
|
||||
</Switch>
|
||||
</SidebarBase>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,36 +3,36 @@ import styled, { css } from "styled-components";
|
||||
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
|
||||
|
||||
export default styled.div`
|
||||
height: 100%;
|
||||
display: flex;
|
||||
user-select: none;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
user-select: none;
|
||||
flex-direction: row;
|
||||
align-items: stretch;
|
||||
`;
|
||||
|
||||
export const GenericSidebarBase = styled.div<{ padding?: boolean }>`
|
||||
height: 100%;
|
||||
width: 240px;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
background: var(--secondary-background);
|
||||
border-end-start-radius: 8px;
|
||||
height: 100%;
|
||||
width: 240px;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
background: var(--secondary-background);
|
||||
border-end-start-radius: 8px;
|
||||
|
||||
${(props) =>
|
||||
props.padding &&
|
||||
isTouchscreenDevice &&
|
||||
css`
|
||||
padding-bottom: 50px;
|
||||
`}
|
||||
${(props) =>
|
||||
props.padding &&
|
||||
isTouchscreenDevice &&
|
||||
css`
|
||||
padding-bottom: 50px;
|
||||
`}
|
||||
`;
|
||||
|
||||
export const GenericSidebarList = styled.div`
|
||||
padding: 6px;
|
||||
flex-grow: 1;
|
||||
overflow-y: scroll;
|
||||
padding: 6px;
|
||||
flex-grow: 1;
|
||||
overflow-y: scroll;
|
||||
|
||||
> img {
|
||||
width: 100%;
|
||||
}
|
||||
> img {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -20,204 +20,204 @@ import IconButton from "../../ui/IconButton";
|
||||
import { Children } from "../../../types/Preact";
|
||||
|
||||
type CommonProps = Omit<
|
||||
JSX.HTMLAttributes<HTMLDivElement>,
|
||||
"children" | "as"
|
||||
JSX.HTMLAttributes<HTMLDivElement>,
|
||||
"children" | "as"
|
||||
> & {
|
||||
active?: boolean;
|
||||
alert?: "unread" | "mention";
|
||||
alertCount?: number;
|
||||
active?: boolean;
|
||||
alert?: "unread" | "mention";
|
||||
alertCount?: number;
|
||||
};
|
||||
|
||||
type UserProps = CommonProps & {
|
||||
user: Users.User;
|
||||
context?: Channels.Channel;
|
||||
channel?: Channels.DirectMessageChannel;
|
||||
user: Users.User;
|
||||
context?: Channels.Channel;
|
||||
channel?: Channels.DirectMessageChannel;
|
||||
};
|
||||
|
||||
export function UserButton(props: UserProps) {
|
||||
const { active, alert, alertCount, user, context, channel, ...divProps } =
|
||||
props;
|
||||
const { openScreen } = useIntermediate();
|
||||
const { active, alert, alertCount, user, context, channel, ...divProps } =
|
||||
props;
|
||||
const { openScreen } = useIntermediate();
|
||||
|
||||
return (
|
||||
<div
|
||||
{...divProps}
|
||||
className={classNames(styles.item, styles.user)}
|
||||
data-active={active}
|
||||
data-alert={typeof alert === "string"}
|
||||
data-online={
|
||||
typeof channel !== "undefined" ||
|
||||
(user.online &&
|
||||
user.status?.presence !== Users.Presence.Invisible)
|
||||
}
|
||||
onContextMenu={attachContextMenu("Menu", {
|
||||
user: user._id,
|
||||
channel: channel?._id,
|
||||
unread: alert,
|
||||
contextualChannel: context?._id,
|
||||
})}>
|
||||
<UserIcon
|
||||
className={styles.avatar}
|
||||
target={user}
|
||||
size={32}
|
||||
status
|
||||
/>
|
||||
<div className={styles.name}>
|
||||
<div>{user.username}</div>
|
||||
{
|
||||
<div className={styles.subText}>
|
||||
{channel?.last_message && alert ? (
|
||||
channel.last_message.short
|
||||
) : (
|
||||
<UserStatus user={user} />
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div className={styles.button}>
|
||||
{context?.channel_type === "Group" &&
|
||||
context.owner === user._id && (
|
||||
<Localizer>
|
||||
<Tooltip
|
||||
content={<Text id="app.main.groups.owner" />}>
|
||||
<Crown size={20} />
|
||||
</Tooltip>
|
||||
</Localizer>
|
||||
)}
|
||||
{alert && (
|
||||
<div className={styles.alert} data-style={alert}>
|
||||
{alertCount}
|
||||
</div>
|
||||
)}
|
||||
{!isTouchscreenDevice && channel && (
|
||||
<IconButton
|
||||
className={styles.icon}
|
||||
onClick={(e) =>
|
||||
stopPropagation(e) &&
|
||||
openScreen({
|
||||
id: "special_prompt",
|
||||
type: "close_dm",
|
||||
target: channel,
|
||||
})
|
||||
}>
|
||||
<X size={24} />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
{...divProps}
|
||||
className={classNames(styles.item, styles.user)}
|
||||
data-active={active}
|
||||
data-alert={typeof alert === "string"}
|
||||
data-online={
|
||||
typeof channel !== "undefined" ||
|
||||
(user.online &&
|
||||
user.status?.presence !== Users.Presence.Invisible)
|
||||
}
|
||||
onContextMenu={attachContextMenu("Menu", {
|
||||
user: user._id,
|
||||
channel: channel?._id,
|
||||
unread: alert,
|
||||
contextualChannel: context?._id,
|
||||
})}>
|
||||
<UserIcon
|
||||
className={styles.avatar}
|
||||
target={user}
|
||||
size={32}
|
||||
status
|
||||
/>
|
||||
<div className={styles.name}>
|
||||
<div>{user.username}</div>
|
||||
{
|
||||
<div className={styles.subText}>
|
||||
{channel?.last_message && alert ? (
|
||||
channel.last_message.short
|
||||
) : (
|
||||
<UserStatus user={user} />
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div className={styles.button}>
|
||||
{context?.channel_type === "Group" &&
|
||||
context.owner === user._id && (
|
||||
<Localizer>
|
||||
<Tooltip
|
||||
content={<Text id="app.main.groups.owner" />}>
|
||||
<Crown size={20} />
|
||||
</Tooltip>
|
||||
</Localizer>
|
||||
)}
|
||||
{alert && (
|
||||
<div className={styles.alert} data-style={alert}>
|
||||
{alertCount}
|
||||
</div>
|
||||
)}
|
||||
{!isTouchscreenDevice && channel && (
|
||||
<IconButton
|
||||
className={styles.icon}
|
||||
onClick={(e) =>
|
||||
stopPropagation(e) &&
|
||||
openScreen({
|
||||
id: "special_prompt",
|
||||
type: "close_dm",
|
||||
target: channel,
|
||||
})
|
||||
}>
|
||||
<X size={24} />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ChannelProps = CommonProps & {
|
||||
channel: Channels.Channel & { unread?: string };
|
||||
user?: Users.User;
|
||||
compact?: boolean;
|
||||
channel: Channels.Channel & { unread?: string };
|
||||
user?: Users.User;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export function ChannelButton(props: ChannelProps) {
|
||||
const { active, alert, alertCount, channel, user, compact, ...divProps } =
|
||||
props;
|
||||
const { active, alert, alertCount, channel, user, compact, ...divProps } =
|
||||
props;
|
||||
|
||||
if (channel.channel_type === "SavedMessages") throw "Invalid channel type.";
|
||||
if (channel.channel_type === "DirectMessage") {
|
||||
if (typeof user === "undefined") throw "No user provided.";
|
||||
return <UserButton {...{ active, alert, channel, user }} />;
|
||||
}
|
||||
if (channel.channel_type === "SavedMessages") throw "Invalid channel type.";
|
||||
if (channel.channel_type === "DirectMessage") {
|
||||
if (typeof user === "undefined") throw "No user provided.";
|
||||
return <UserButton {...{ active, alert, channel, user }} />;
|
||||
}
|
||||
|
||||
const { openScreen } = useIntermediate();
|
||||
const { openScreen } = useIntermediate();
|
||||
|
||||
return (
|
||||
<div
|
||||
{...divProps}
|
||||
data-active={active}
|
||||
data-alert={typeof alert === "string"}
|
||||
aria-label={{}} /*FIXME: ADD ARIA LABEL*/
|
||||
className={classNames(styles.item, { [styles.compact]: compact })}
|
||||
onContextMenu={attachContextMenu("Menu", {
|
||||
channel: channel._id,
|
||||
unread: typeof channel.unread !== "undefined",
|
||||
})}>
|
||||
<ChannelIcon
|
||||
className={styles.avatar}
|
||||
target={channel}
|
||||
size={compact ? 24 : 32}
|
||||
/>
|
||||
<div className={styles.name}>
|
||||
<div>{channel.name}</div>
|
||||
{channel.channel_type === "Group" && (
|
||||
<div className={styles.subText}>
|
||||
{channel.last_message && alert ? (
|
||||
channel.last_message.short
|
||||
) : (
|
||||
<Text
|
||||
id="quantities.members"
|
||||
plural={channel.recipients.length}
|
||||
fields={{ count: channel.recipients.length }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.button}>
|
||||
{alert && (
|
||||
<div className={styles.alert} data-style={alert}>
|
||||
{alertCount}
|
||||
</div>
|
||||
)}
|
||||
{!isTouchscreenDevice && channel.channel_type === "Group" && (
|
||||
<IconButton
|
||||
className={styles.icon}
|
||||
onClick={() =>
|
||||
openScreen({
|
||||
id: "special_prompt",
|
||||
type: "leave_group",
|
||||
target: channel,
|
||||
})
|
||||
}>
|
||||
<X size={24} />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
{...divProps}
|
||||
data-active={active}
|
||||
data-alert={typeof alert === "string"}
|
||||
aria-label={{}} /*FIXME: ADD ARIA LABEL*/
|
||||
className={classNames(styles.item, { [styles.compact]: compact })}
|
||||
onContextMenu={attachContextMenu("Menu", {
|
||||
channel: channel._id,
|
||||
unread: typeof channel.unread !== "undefined",
|
||||
})}>
|
||||
<ChannelIcon
|
||||
className={styles.avatar}
|
||||
target={channel}
|
||||
size={compact ? 24 : 32}
|
||||
/>
|
||||
<div className={styles.name}>
|
||||
<div>{channel.name}</div>
|
||||
{channel.channel_type === "Group" && (
|
||||
<div className={styles.subText}>
|
||||
{channel.last_message && alert ? (
|
||||
channel.last_message.short
|
||||
) : (
|
||||
<Text
|
||||
id="quantities.members"
|
||||
plural={channel.recipients.length}
|
||||
fields={{ count: channel.recipients.length }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.button}>
|
||||
{alert && (
|
||||
<div className={styles.alert} data-style={alert}>
|
||||
{alertCount}
|
||||
</div>
|
||||
)}
|
||||
{!isTouchscreenDevice && channel.channel_type === "Group" && (
|
||||
<IconButton
|
||||
className={styles.icon}
|
||||
onClick={() =>
|
||||
openScreen({
|
||||
id: "special_prompt",
|
||||
type: "leave_group",
|
||||
target: channel,
|
||||
})
|
||||
}>
|
||||
<X size={24} />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type ButtonProps = CommonProps & {
|
||||
onClick?: () => void;
|
||||
children?: Children;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
onClick?: () => void;
|
||||
children?: Children;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
};
|
||||
|
||||
export default function ButtonItem(props: ButtonProps) {
|
||||
const {
|
||||
active,
|
||||
alert,
|
||||
alertCount,
|
||||
onClick,
|
||||
className,
|
||||
children,
|
||||
compact,
|
||||
...divProps
|
||||
} = props;
|
||||
const {
|
||||
active,
|
||||
alert,
|
||||
alertCount,
|
||||
onClick,
|
||||
className,
|
||||
children,
|
||||
compact,
|
||||
...divProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
{...divProps}
|
||||
className={classNames(
|
||||
styles.item,
|
||||
{ [styles.compact]: compact, [styles.normal]: !compact },
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
data-active={active}
|
||||
data-alert={typeof alert === "string"}>
|
||||
<div className={styles.content}>{children}</div>
|
||||
{alert && (
|
||||
<div className={styles.alert} data-style={alert}>
|
||||
{alertCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div
|
||||
{...divProps}
|
||||
className={classNames(
|
||||
styles.item,
|
||||
{ [styles.compact]: compact, [styles.normal]: !compact },
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
data-active={active}
|
||||
data-alert={typeof alert === "string"}>
|
||||
<div className={styles.content}>{children}</div>
|
||||
{alert && (
|
||||
<div className={styles.alert} data-style={alert}>
|
||||
{alertCount}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,39 +2,39 @@ import { Text } from "preact-i18n";
|
||||
import { useContext } from "preact/hooks";
|
||||
|
||||
import {
|
||||
ClientStatus,
|
||||
StatusContext,
|
||||
ClientStatus,
|
||||
StatusContext,
|
||||
} from "../../../context/revoltjs/RevoltClient";
|
||||
|
||||
import Banner from "../../ui/Banner";
|
||||
|
||||
export default function ConnectionStatus() {
|
||||
const status = useContext(StatusContext);
|
||||
const status = useContext(StatusContext);
|
||||
|
||||
if (status === ClientStatus.OFFLINE) {
|
||||
return (
|
||||
<Banner>
|
||||
<Text id="app.special.status.offline" />
|
||||
</Banner>
|
||||
);
|
||||
} else if (status === ClientStatus.DISCONNECTED) {
|
||||
return (
|
||||
<Banner>
|
||||
<Text id="app.special.status.disconnected" />
|
||||
</Banner>
|
||||
);
|
||||
} else if (status === ClientStatus.CONNECTING) {
|
||||
return (
|
||||
<Banner>
|
||||
<Text id="app.special.status.connecting" />
|
||||
</Banner>
|
||||
);
|
||||
} else if (status === ClientStatus.RECONNECTING) {
|
||||
return (
|
||||
<Banner>
|
||||
<Text id="app.special.status.reconnecting" />
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
if (status === ClientStatus.OFFLINE) {
|
||||
return (
|
||||
<Banner>
|
||||
<Text id="app.special.status.offline" />
|
||||
</Banner>
|
||||
);
|
||||
} else if (status === ClientStatus.DISCONNECTED) {
|
||||
return (
|
||||
<Banner>
|
||||
<Text id="app.special.status.disconnected" />
|
||||
</Banner>
|
||||
);
|
||||
} else if (status === ClientStatus.CONNECTING) {
|
||||
return (
|
||||
<Banner>
|
||||
<Text id="app.special.status.connecting" />
|
||||
</Banner>
|
||||
);
|
||||
} else if (status === ClientStatus.RECONNECTING) {
|
||||
return (
|
||||
<Banner>
|
||||
<Text id="app.special.status.reconnecting" />
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import {
|
||||
Home,
|
||||
UserDetail,
|
||||
Wrench,
|
||||
Notepad,
|
||||
Home,
|
||||
UserDetail,
|
||||
Wrench,
|
||||
Notepad,
|
||||
} from "@styled-icons/boxicons-solid";
|
||||
import { Link, Redirect, useLocation, useParams } from "react-router-dom";
|
||||
import { Channels } from "revolt.js/dist/api/objects";
|
||||
@@ -22,9 +22,9 @@ import { Unreads } from "../../../redux/reducers/unreads";
|
||||
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
||||
import { AppContext } from "../../../context/revoltjs/RevoltClient";
|
||||
import {
|
||||
useDMs,
|
||||
useForceUpdate,
|
||||
useUsers,
|
||||
useDMs,
|
||||
useForceUpdate,
|
||||
useUsers,
|
||||
} from "../../../context/revoltjs/hooks";
|
||||
|
||||
import UserHeader from "../../common/user/UserHeader";
|
||||
@@ -37,157 +37,157 @@ import ButtonItem, { ChannelButton } from "../items/ButtonItem";
|
||||
import ConnectionStatus from "../items/ConnectionStatus";
|
||||
|
||||
type Props = {
|
||||
unreads: Unreads;
|
||||
unreads: Unreads;
|
||||
};
|
||||
|
||||
function HomeSidebar(props: Props) {
|
||||
const { pathname } = useLocation();
|
||||
const client = useContext(AppContext);
|
||||
const { channel } = useParams<{ channel: string }>();
|
||||
const { openScreen } = useIntermediate();
|
||||
const { pathname } = useLocation();
|
||||
const client = useContext(AppContext);
|
||||
const { channel } = useParams<{ channel: string }>();
|
||||
const { openScreen } = useIntermediate();
|
||||
|
||||
const ctx = useForceUpdate();
|
||||
const channels = useDMs(ctx);
|
||||
const ctx = useForceUpdate();
|
||||
const channels = useDMs(ctx);
|
||||
|
||||
const obj = channels.find((x) => x?._id === channel);
|
||||
if (channel && !obj) return <Redirect to="/" />;
|
||||
if (obj) useUnreads({ ...props, channel: obj });
|
||||
const obj = channels.find((x) => x?._id === channel);
|
||||
if (channel && !obj) return <Redirect to="/" />;
|
||||
if (obj) useUnreads({ ...props, channel: obj });
|
||||
|
||||
useEffect(() => {
|
||||
if (!channel) return;
|
||||
useEffect(() => {
|
||||
if (!channel) return;
|
||||
|
||||
dispatch({
|
||||
type: "LAST_OPENED_SET",
|
||||
parent: "home",
|
||||
child: channel,
|
||||
});
|
||||
}, [channel]);
|
||||
dispatch({
|
||||
type: "LAST_OPENED_SET",
|
||||
parent: "home",
|
||||
child: channel,
|
||||
});
|
||||
}, [channel]);
|
||||
|
||||
const channelsArr = channels
|
||||
.filter((x) => x.channel_type !== "SavedMessages")
|
||||
.map((x) => mapChannelWithUnread(x, props.unreads));
|
||||
const channelsArr = channels
|
||||
.filter((x) => x.channel_type !== "SavedMessages")
|
||||
.map((x) => mapChannelWithUnread(x, props.unreads));
|
||||
|
||||
const users = useUsers(
|
||||
(
|
||||
channelsArr as (
|
||||
| Channels.DirectMessageChannel
|
||||
| Channels.GroupChannel
|
||||
)[]
|
||||
).reduce((prev: any, cur) => [...prev, ...cur.recipients], []),
|
||||
ctx,
|
||||
);
|
||||
const users = useUsers(
|
||||
(
|
||||
channelsArr as (
|
||||
| Channels.DirectMessageChannel
|
||||
| Channels.GroupChannel
|
||||
)[]
|
||||
).reduce((prev: any, cur) => [...prev, ...cur.recipients], []),
|
||||
ctx,
|
||||
);
|
||||
|
||||
channelsArr.sort((b, a) => a.timestamp.localeCompare(b.timestamp));
|
||||
channelsArr.sort((b, a) => a.timestamp.localeCompare(b.timestamp));
|
||||
|
||||
return (
|
||||
<GenericSidebarBase padding>
|
||||
<UserHeader user={client.user!} />
|
||||
<ConnectionStatus />
|
||||
<GenericSidebarList>
|
||||
{!isTouchscreenDevice && (
|
||||
<>
|
||||
<ConditionalLink active={pathname === "/"} to="/">
|
||||
<ButtonItem active={pathname === "/"}>
|
||||
<Home size={20} />
|
||||
<span>
|
||||
<Text id="app.navigation.tabs.home" />
|
||||
</span>
|
||||
</ButtonItem>
|
||||
</ConditionalLink>
|
||||
<ConditionalLink
|
||||
active={pathname === "/friends"}
|
||||
to="/friends">
|
||||
<ButtonItem
|
||||
active={pathname === "/friends"}
|
||||
alert={
|
||||
typeof users.find(
|
||||
(user) =>
|
||||
user?.relationship ===
|
||||
UsersNS.Relationship.Incoming,
|
||||
) !== "undefined"
|
||||
? "unread"
|
||||
: undefined
|
||||
}>
|
||||
<UserDetail size={20} />
|
||||
<span>
|
||||
<Text id="app.navigation.tabs.friends" />
|
||||
</span>
|
||||
</ButtonItem>
|
||||
</ConditionalLink>
|
||||
</>
|
||||
)}
|
||||
<ConditionalLink
|
||||
active={obj?.channel_type === "SavedMessages"}
|
||||
to="/open/saved">
|
||||
<ButtonItem active={obj?.channel_type === "SavedMessages"}>
|
||||
<Notepad size={20} />
|
||||
<span>
|
||||
<Text id="app.navigation.tabs.saved" />
|
||||
</span>
|
||||
</ButtonItem>
|
||||
</ConditionalLink>
|
||||
{import.meta.env.DEV && (
|
||||
<Link to="/dev">
|
||||
<ButtonItem active={pathname === "/dev"}>
|
||||
<Wrench size={20} />
|
||||
<span>
|
||||
<Text id="app.navigation.tabs.dev" />
|
||||
</span>
|
||||
</ButtonItem>
|
||||
</Link>
|
||||
)}
|
||||
<Category
|
||||
text={<Text id="app.main.categories.conversations" />}
|
||||
action={() =>
|
||||
openScreen({
|
||||
id: "special_input",
|
||||
type: "create_group",
|
||||
})
|
||||
}
|
||||
/>
|
||||
{channelsArr.length === 0 && <img src={placeholderSVG} />}
|
||||
{channelsArr.map((x) => {
|
||||
let user;
|
||||
if (x.channel_type === "DirectMessage") {
|
||||
if (!x.active) return null;
|
||||
return (
|
||||
<GenericSidebarBase padding>
|
||||
<UserHeader user={client.user!} />
|
||||
<ConnectionStatus />
|
||||
<GenericSidebarList>
|
||||
{!isTouchscreenDevice && (
|
||||
<>
|
||||
<ConditionalLink active={pathname === "/"} to="/">
|
||||
<ButtonItem active={pathname === "/"}>
|
||||
<Home size={20} />
|
||||
<span>
|
||||
<Text id="app.navigation.tabs.home" />
|
||||
</span>
|
||||
</ButtonItem>
|
||||
</ConditionalLink>
|
||||
<ConditionalLink
|
||||
active={pathname === "/friends"}
|
||||
to="/friends">
|
||||
<ButtonItem
|
||||
active={pathname === "/friends"}
|
||||
alert={
|
||||
typeof users.find(
|
||||
(user) =>
|
||||
user?.relationship ===
|
||||
UsersNS.Relationship.Incoming,
|
||||
) !== "undefined"
|
||||
? "unread"
|
||||
: undefined
|
||||
}>
|
||||
<UserDetail size={20} />
|
||||
<span>
|
||||
<Text id="app.navigation.tabs.friends" />
|
||||
</span>
|
||||
</ButtonItem>
|
||||
</ConditionalLink>
|
||||
</>
|
||||
)}
|
||||
<ConditionalLink
|
||||
active={obj?.channel_type === "SavedMessages"}
|
||||
to="/open/saved">
|
||||
<ButtonItem active={obj?.channel_type === "SavedMessages"}>
|
||||
<Notepad size={20} />
|
||||
<span>
|
||||
<Text id="app.navigation.tabs.saved" />
|
||||
</span>
|
||||
</ButtonItem>
|
||||
</ConditionalLink>
|
||||
{import.meta.env.DEV && (
|
||||
<Link to="/dev">
|
||||
<ButtonItem active={pathname === "/dev"}>
|
||||
<Wrench size={20} />
|
||||
<span>
|
||||
<Text id="app.navigation.tabs.dev" />
|
||||
</span>
|
||||
</ButtonItem>
|
||||
</Link>
|
||||
)}
|
||||
<Category
|
||||
text={<Text id="app.main.categories.conversations" />}
|
||||
action={() =>
|
||||
openScreen({
|
||||
id: "special_input",
|
||||
type: "create_group",
|
||||
})
|
||||
}
|
||||
/>
|
||||
{channelsArr.length === 0 && <img src={placeholderSVG} />}
|
||||
{channelsArr.map((x) => {
|
||||
let user;
|
||||
if (x.channel_type === "DirectMessage") {
|
||||
if (!x.active) return null;
|
||||
|
||||
let recipient = client.channels.getRecipient(x._id);
|
||||
user = users.find((x) => x?._id === recipient);
|
||||
let recipient = client.channels.getRecipient(x._id);
|
||||
user = users.find((x) => x?._id === recipient);
|
||||
|
||||
if (!user) {
|
||||
console.warn(
|
||||
`Skipped DM ${x._id} because user was missing.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!user) {
|
||||
console.warn(
|
||||
`Skipped DM ${x._id} because user was missing.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ConditionalLink
|
||||
active={x._id === channel}
|
||||
to={`/channel/${x._id}`}>
|
||||
<ChannelButton
|
||||
user={user}
|
||||
channel={x}
|
||||
alert={x.unread}
|
||||
alertCount={x.alertCount}
|
||||
active={x._id === channel}
|
||||
/>
|
||||
</ConditionalLink>
|
||||
);
|
||||
})}
|
||||
<PaintCounter />
|
||||
</GenericSidebarList>
|
||||
</GenericSidebarBase>
|
||||
);
|
||||
return (
|
||||
<ConditionalLink
|
||||
active={x._id === channel}
|
||||
to={`/channel/${x._id}`}>
|
||||
<ChannelButton
|
||||
user={user}
|
||||
channel={x}
|
||||
alert={x.unread}
|
||||
alertCount={x.alertCount}
|
||||
active={x._id === channel}
|
||||
/>
|
||||
</ConditionalLink>
|
||||
);
|
||||
})}
|
||||
<PaintCounter />
|
||||
</GenericSidebarList>
|
||||
</GenericSidebarBase>
|
||||
);
|
||||
}
|
||||
|
||||
export default connectState(
|
||||
HomeSidebar,
|
||||
(state) => {
|
||||
return {
|
||||
unreads: state.unreads,
|
||||
};
|
||||
},
|
||||
true,
|
||||
HomeSidebar,
|
||||
(state) => {
|
||||
return {
|
||||
unreads: state.unreads,
|
||||
};
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
@@ -15,10 +15,10 @@ import { Unreads } from "../../../redux/reducers/unreads";
|
||||
|
||||
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
||||
import {
|
||||
useChannels,
|
||||
useForceUpdate,
|
||||
useSelf,
|
||||
useServers,
|
||||
useChannels,
|
||||
useForceUpdate,
|
||||
useSelf,
|
||||
useServers,
|
||||
} from "../../../context/revoltjs/hooks";
|
||||
|
||||
import ServerIcon from "../../common/ServerIcon";
|
||||
@@ -31,268 +31,268 @@ import { mapChannelWithUnread } from "./common";
|
||||
import { Children } from "../../../types/Preact";
|
||||
|
||||
function Icon({
|
||||
children,
|
||||
unread,
|
||||
size,
|
||||
children,
|
||||
unread,
|
||||
size,
|
||||
}: {
|
||||
children: Children;
|
||||
unread?: "mention" | "unread";
|
||||
size: number;
|
||||
children: Children;
|
||||
unread?: "mention" | "unread";
|
||||
size: number;
|
||||
}) {
|
||||
return (
|
||||
<svg width={size} height={size} aria-hidden="true" viewBox="0 0 32 32">
|
||||
<use href="#serverIndicator" />
|
||||
<foreignObject
|
||||
x="0"
|
||||
y="0"
|
||||
width="32"
|
||||
height="32"
|
||||
mask={unread ? "url(#server)" : undefined}>
|
||||
{children}
|
||||
</foreignObject>
|
||||
{unread === "unread" && (
|
||||
<circle cx="27" cy="5" r="5" fill={"white"} />
|
||||
)}
|
||||
{unread === "mention" && (
|
||||
<circle cx="27" cy="5" r="5" fill={"red"} />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg width={size} height={size} aria-hidden="true" viewBox="0 0 32 32">
|
||||
<use href="#serverIndicator" />
|
||||
<foreignObject
|
||||
x="0"
|
||||
y="0"
|
||||
width="32"
|
||||
height="32"
|
||||
mask={unread ? "url(#server)" : undefined}>
|
||||
{children}
|
||||
</foreignObject>
|
||||
{unread === "unread" && (
|
||||
<circle cx="27" cy="5" r="5" fill={"white"} />
|
||||
)}
|
||||
{unread === "mention" && (
|
||||
<circle cx="27" cy="5" r="5" fill={"red"} />
|
||||
)}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const ServersBase = styled.div`
|
||||
width: 56px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 56px;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
${isTouchscreenDevice &&
|
||||
css`
|
||||
padding-bottom: 50px;
|
||||
`}
|
||||
${isTouchscreenDevice &&
|
||||
css`
|
||||
padding-bottom: 50px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const ServerList = styled.div`
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
overflow-y: scroll;
|
||||
padding-bottom: 48px;
|
||||
flex-direction: column;
|
||||
// border-right: 2px solid var(--accent);
|
||||
flex-grow: 1;
|
||||
display: flex;
|
||||
overflow-y: scroll;
|
||||
padding-bottom: 48px;
|
||||
flex-direction: column;
|
||||
// border-right: 2px solid var(--accent);
|
||||
|
||||
scrollbar-width: none;
|
||||
scrollbar-width: none;
|
||||
|
||||
> :first-child > svg {
|
||||
margin: 6px 0 6px 4px;
|
||||
}
|
||||
> :first-child > svg {
|
||||
margin: 6px 0 6px 4px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0px;
|
||||
}
|
||||
&::-webkit-scrollbar {
|
||||
width: 0px;
|
||||
}
|
||||
`;
|
||||
|
||||
const ServerEntry = styled.div<{ active: boolean; home?: boolean }>`
|
||||
height: 58px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
height: 58px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
|
||||
> * {
|
||||
// outline: 1px solid red;
|
||||
}
|
||||
> * {
|
||||
// outline: 1px solid red;
|
||||
}
|
||||
|
||||
> div {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
> div {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
|
||||
border-start-start-radius: 50%;
|
||||
border-end-start-radius: 50%;
|
||||
border-start-start-radius: 50%;
|
||||
border-end-start-radius: 50%;
|
||||
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
&:active {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.active &&
|
||||
css`
|
||||
background: var(--sidebar-active);
|
||||
&:active {
|
||||
transform: none;
|
||||
}
|
||||
`}
|
||||
}
|
||||
${(props) =>
|
||||
props.active &&
|
||||
css`
|
||||
background: var(--sidebar-active);
|
||||
&:active {
|
||||
transform: none;
|
||||
}
|
||||
`}
|
||||
}
|
||||
|
||||
span {
|
||||
width: 6px;
|
||||
height: 46px;
|
||||
span {
|
||||
width: 6px;
|
||||
height: 46px;
|
||||
|
||||
${(props) =>
|
||||
props.active &&
|
||||
css`
|
||||
background-color: var(--sidebar-active);
|
||||
${(props) =>
|
||||
props.active &&
|
||||
css`
|
||||
background-color: var(--sidebar-active);
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
// outline: 1px solid blue;
|
||||
}
|
||||
&::before,
|
||||
&::after {
|
||||
// outline: 1px solid blue;
|
||||
}
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: relative;
|
||||
&::before,
|
||||
&::after {
|
||||
content: "";
|
||||
display: block;
|
||||
position: relative;
|
||||
|
||||
width: 31px;
|
||||
height: 72px;
|
||||
margin-top: -72px;
|
||||
margin-left: -25px;
|
||||
z-index: -1;
|
||||
width: 31px;
|
||||
height: 72px;
|
||||
margin-top: -72px;
|
||||
margin-left: -25px;
|
||||
z-index: -1;
|
||||
|
||||
background-color: var(--background);
|
||||
border-bottom-right-radius: 32px;
|
||||
background-color: var(--background);
|
||||
border-bottom-right-radius: 32px;
|
||||
|
||||
box-shadow: 0 32px 0 0 var(--sidebar-active);
|
||||
}
|
||||
box-shadow: 0 32px 0 0 var(--sidebar-active);
|
||||
}
|
||||
|
||||
&::after {
|
||||
transform: scaleY(-1) translateY(-118px);
|
||||
}
|
||||
`}
|
||||
}
|
||||
&::after {
|
||||
transform: scaleY(-1) translateY(-118px);
|
||||
}
|
||||
`}
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
(!props.active || props.home) &&
|
||||
css`
|
||||
cursor: pointer;
|
||||
`}
|
||||
${(props) =>
|
||||
(!props.active || props.home) &&
|
||||
css`
|
||||
cursor: pointer;
|
||||
`}
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
unreads: Unreads;
|
||||
lastOpened: LastOpened;
|
||||
unreads: Unreads;
|
||||
lastOpened: LastOpened;
|
||||
}
|
||||
|
||||
export function ServerListSidebar({ unreads, lastOpened }: Props) {
|
||||
const ctx = useForceUpdate();
|
||||
const self = useSelf(ctx);
|
||||
const activeServers = useServers(undefined, ctx) as Servers.Server[];
|
||||
const channels = (useChannels(undefined, ctx) as Channel[]).map((x) =>
|
||||
mapChannelWithUnread(x, unreads),
|
||||
);
|
||||
const ctx = useForceUpdate();
|
||||
const self = useSelf(ctx);
|
||||
const activeServers = useServers(undefined, ctx) as Servers.Server[];
|
||||
const channels = (useChannels(undefined, ctx) as Channel[]).map((x) =>
|
||||
mapChannelWithUnread(x, unreads),
|
||||
);
|
||||
|
||||
const unreadChannels = channels.filter((x) => x.unread).map((x) => x._id);
|
||||
const unreadChannels = channels.filter((x) => x.unread).map((x) => x._id);
|
||||
|
||||
const servers = activeServers.map((server) => {
|
||||
let alertCount = 0;
|
||||
for (let id of server.channels) {
|
||||
let channel = channels.find((x) => x._id === id);
|
||||
if (channel?.alertCount) {
|
||||
alertCount += channel.alertCount;
|
||||
}
|
||||
}
|
||||
const servers = activeServers.map((server) => {
|
||||
let alertCount = 0;
|
||||
for (let id of server.channels) {
|
||||
let channel = channels.find((x) => x._id === id);
|
||||
if (channel?.alertCount) {
|
||||
alertCount += channel.alertCount;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...server,
|
||||
unread: (typeof server.channels.find((x) =>
|
||||
unreadChannels.includes(x),
|
||||
) !== "undefined"
|
||||
? alertCount > 0
|
||||
? "mention"
|
||||
: "unread"
|
||||
: undefined) as "mention" | "unread" | undefined,
|
||||
alertCount,
|
||||
};
|
||||
});
|
||||
return {
|
||||
...server,
|
||||
unread: (typeof server.channels.find((x) =>
|
||||
unreadChannels.includes(x),
|
||||
) !== "undefined"
|
||||
? alertCount > 0
|
||||
? "mention"
|
||||
: "unread"
|
||||
: undefined) as "mention" | "unread" | undefined,
|
||||
alertCount,
|
||||
};
|
||||
});
|
||||
|
||||
const path = useLocation().pathname;
|
||||
const { server: server_id } = useParams<{ server?: string }>();
|
||||
const server = servers.find((x) => x!._id == server_id);
|
||||
const path = useLocation().pathname;
|
||||
const { server: server_id } = useParams<{ server?: string }>();
|
||||
const server = servers.find((x) => x!._id == server_id);
|
||||
|
||||
const { openScreen } = useIntermediate();
|
||||
const { openScreen } = useIntermediate();
|
||||
|
||||
let homeUnread: "mention" | "unread" | undefined;
|
||||
let alertCount = 0;
|
||||
for (let x of channels) {
|
||||
if (
|
||||
((x.channel_type === "DirectMessage" && x.active) ||
|
||||
x.channel_type === "Group") &&
|
||||
x.unread
|
||||
) {
|
||||
homeUnread = "unread";
|
||||
alertCount += x.alertCount ?? 0;
|
||||
}
|
||||
}
|
||||
let homeUnread: "mention" | "unread" | undefined;
|
||||
let alertCount = 0;
|
||||
for (let x of channels) {
|
||||
if (
|
||||
((x.channel_type === "DirectMessage" && x.active) ||
|
||||
x.channel_type === "Group") &&
|
||||
x.unread
|
||||
) {
|
||||
homeUnread = "unread";
|
||||
alertCount += x.alertCount ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (alertCount > 0) homeUnread = "mention";
|
||||
const homeActive =
|
||||
typeof server === "undefined" && !path.startsWith("/invite");
|
||||
if (alertCount > 0) homeUnread = "mention";
|
||||
const homeActive =
|
||||
typeof server === "undefined" && !path.startsWith("/invite");
|
||||
|
||||
return (
|
||||
<ServersBase>
|
||||
<ServerList>
|
||||
<ConditionalLink
|
||||
active={homeActive}
|
||||
to={lastOpened.home ? `/channel/${lastOpened.home}` : "/"}>
|
||||
<ServerEntry home active={homeActive}>
|
||||
<div
|
||||
onContextMenu={attachContextMenu("Status")}
|
||||
onClick={() =>
|
||||
homeActive && openContextMenu("Status")
|
||||
}>
|
||||
<Icon size={42} unread={homeUnread}>
|
||||
<UserIcon target={self} size={32} status />
|
||||
</Icon>
|
||||
</div>
|
||||
<span />
|
||||
</ServerEntry>
|
||||
</ConditionalLink>
|
||||
<LineDivider />
|
||||
{servers.map((entry) => {
|
||||
const active = entry!._id === server?._id;
|
||||
const id = lastOpened[entry!._id];
|
||||
return (
|
||||
<ServersBase>
|
||||
<ServerList>
|
||||
<ConditionalLink
|
||||
active={homeActive}
|
||||
to={lastOpened.home ? `/channel/${lastOpened.home}` : "/"}>
|
||||
<ServerEntry home active={homeActive}>
|
||||
<div
|
||||
onContextMenu={attachContextMenu("Status")}
|
||||
onClick={() =>
|
||||
homeActive && openContextMenu("Status")
|
||||
}>
|
||||
<Icon size={42} unread={homeUnread}>
|
||||
<UserIcon target={self} size={32} status />
|
||||
</Icon>
|
||||
</div>
|
||||
<span />
|
||||
</ServerEntry>
|
||||
</ConditionalLink>
|
||||
<LineDivider />
|
||||
{servers.map((entry) => {
|
||||
const active = entry!._id === server?._id;
|
||||
const id = lastOpened[entry!._id];
|
||||
|
||||
return (
|
||||
<ConditionalLink
|
||||
active={active}
|
||||
to={
|
||||
`/server/${entry!._id}` +
|
||||
(id ? `/channel/${id}` : "")
|
||||
}>
|
||||
<ServerEntry
|
||||
active={active}
|
||||
onContextMenu={attachContextMenu("Menu", {
|
||||
server: entry!._id,
|
||||
})}>
|
||||
<Tooltip content={entry.name} placement="right">
|
||||
<Icon size={42} unread={entry.unread}>
|
||||
<ServerIcon size={32} target={entry} />
|
||||
</Icon>
|
||||
</Tooltip>
|
||||
<span />
|
||||
</ServerEntry>
|
||||
</ConditionalLink>
|
||||
);
|
||||
})}
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
openScreen({
|
||||
id: "special_input",
|
||||
type: "create_server",
|
||||
})
|
||||
}>
|
||||
<Plus size={36} />
|
||||
</IconButton>
|
||||
<PaintCounter small />
|
||||
</ServerList>
|
||||
</ServersBase>
|
||||
);
|
||||
return (
|
||||
<ConditionalLink
|
||||
active={active}
|
||||
to={
|
||||
`/server/${entry!._id}` +
|
||||
(id ? `/channel/${id}` : "")
|
||||
}>
|
||||
<ServerEntry
|
||||
active={active}
|
||||
onContextMenu={attachContextMenu("Menu", {
|
||||
server: entry!._id,
|
||||
})}>
|
||||
<Tooltip content={entry.name} placement="right">
|
||||
<Icon size={42} unread={entry.unread}>
|
||||
<ServerIcon size={32} target={entry} />
|
||||
</Icon>
|
||||
</Tooltip>
|
||||
<span />
|
||||
</ServerEntry>
|
||||
</ConditionalLink>
|
||||
);
|
||||
})}
|
||||
<IconButton
|
||||
onClick={() =>
|
||||
openScreen({
|
||||
id: "special_input",
|
||||
type: "create_server",
|
||||
})
|
||||
}>
|
||||
<Plus size={36} />
|
||||
</IconButton>
|
||||
<PaintCounter small />
|
||||
</ServerList>
|
||||
</ServersBase>
|
||||
);
|
||||
}
|
||||
|
||||
export default connectState(ServerListSidebar, (state) => {
|
||||
return {
|
||||
unreads: state.unreads,
|
||||
lastOpened: state.lastOpened,
|
||||
};
|
||||
return {
|
||||
unreads: state.unreads,
|
||||
lastOpened: state.lastOpened,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -13,9 +13,9 @@ import { connectState } from "../../../redux/connector";
|
||||
import { Unreads } from "../../../redux/reducers/unreads";
|
||||
|
||||
import {
|
||||
useChannels,
|
||||
useForceUpdate,
|
||||
useServer,
|
||||
useChannels,
|
||||
useForceUpdate,
|
||||
useServer,
|
||||
} from "../../../context/revoltjs/hooks";
|
||||
|
||||
import CollapsibleSection from "../../common/CollapsibleSection";
|
||||
@@ -27,124 +27,124 @@ import { ChannelButton } from "../items/ButtonItem";
|
||||
import ConnectionStatus from "../items/ConnectionStatus";
|
||||
|
||||
interface Props {
|
||||
unreads: Unreads;
|
||||
unreads: Unreads;
|
||||
}
|
||||
|
||||
const ServerBase = styled.div`
|
||||
height: 100%;
|
||||
width: 240px;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
background: var(--secondary-background);
|
||||
height: 100%;
|
||||
width: 240px;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
flex-direction: column;
|
||||
background: var(--secondary-background);
|
||||
|
||||
border-start-start-radius: 8px;
|
||||
border-end-start-radius: 8px;
|
||||
overflow: hidden;
|
||||
border-start-start-radius: 8px;
|
||||
border-end-start-radius: 8px;
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const ServerList = styled.div`
|
||||
padding: 6px;
|
||||
flex-grow: 1;
|
||||
overflow-y: scroll;
|
||||
padding: 6px;
|
||||
flex-grow: 1;
|
||||
overflow-y: scroll;
|
||||
|
||||
> svg {
|
||||
width: 100%;
|
||||
}
|
||||
> svg {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
|
||||
function ServerSidebar(props: Props) {
|
||||
const { server: server_id, channel: channel_id } =
|
||||
useParams<{ server?: string; channel?: string }>();
|
||||
const ctx = useForceUpdate();
|
||||
const { server: server_id, channel: channel_id } =
|
||||
useParams<{ server?: string; channel?: string }>();
|
||||
const ctx = useForceUpdate();
|
||||
|
||||
const server = useServer(server_id, ctx);
|
||||
if (!server) return <Redirect to="/" />;
|
||||
const server = useServer(server_id, ctx);
|
||||
if (!server) return <Redirect to="/" />;
|
||||
|
||||
const channels = (
|
||||
useChannels(server.channels, ctx).filter(
|
||||
(entry) => typeof entry !== "undefined",
|
||||
) as Readonly<Channels.TextChannel | Channels.VoiceChannel>[]
|
||||
).map((x) => mapChannelWithUnread(x, props.unreads));
|
||||
const channels = (
|
||||
useChannels(server.channels, ctx).filter(
|
||||
(entry) => typeof entry !== "undefined",
|
||||
) as Readonly<Channels.TextChannel | Channels.VoiceChannel>[]
|
||||
).map((x) => mapChannelWithUnread(x, props.unreads));
|
||||
|
||||
const channel = channels.find((x) => x?._id === channel_id);
|
||||
if (channel_id && !channel) return <Redirect to={`/server/${server_id}`} />;
|
||||
if (channel) useUnreads({ ...props, channel }, ctx);
|
||||
const channel = channels.find((x) => x?._id === channel_id);
|
||||
if (channel_id && !channel) return <Redirect to={`/server/${server_id}`} />;
|
||||
if (channel) useUnreads({ ...props, channel }, ctx);
|
||||
|
||||
useEffect(() => {
|
||||
if (!channel_id) return;
|
||||
useEffect(() => {
|
||||
if (!channel_id) return;
|
||||
|
||||
dispatch({
|
||||
type: "LAST_OPENED_SET",
|
||||
parent: server_id!,
|
||||
child: channel_id!,
|
||||
});
|
||||
}, [channel_id]);
|
||||
dispatch({
|
||||
type: "LAST_OPENED_SET",
|
||||
parent: server_id!,
|
||||
child: channel_id!,
|
||||
});
|
||||
}, [channel_id]);
|
||||
|
||||
let uncategorised = new Set(server.channels);
|
||||
let elements = [];
|
||||
let uncategorised = new Set(server.channels);
|
||||
let elements = [];
|
||||
|
||||
function addChannel(id: string) {
|
||||
const entry = channels.find((x) => x._id === id);
|
||||
if (!entry) return;
|
||||
function addChannel(id: string) {
|
||||
const entry = channels.find((x) => x._id === id);
|
||||
if (!entry) return;
|
||||
|
||||
const active = channel?._id === entry._id;
|
||||
const active = channel?._id === entry._id;
|
||||
|
||||
return (
|
||||
<ConditionalLink
|
||||
key={entry._id}
|
||||
active={active}
|
||||
to={`/server/${server!._id}/channel/${entry._id}`}>
|
||||
<ChannelButton
|
||||
channel={entry}
|
||||
active={active}
|
||||
alert={entry.unread}
|
||||
compact
|
||||
/>
|
||||
</ConditionalLink>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ConditionalLink
|
||||
key={entry._id}
|
||||
active={active}
|
||||
to={`/server/${server!._id}/channel/${entry._id}`}>
|
||||
<ChannelButton
|
||||
channel={entry}
|
||||
active={active}
|
||||
alert={entry.unread}
|
||||
compact
|
||||
/>
|
||||
</ConditionalLink>
|
||||
);
|
||||
}
|
||||
|
||||
if (server.categories) {
|
||||
for (let category of server.categories) {
|
||||
let channels = [];
|
||||
for (let id of category.channels) {
|
||||
uncategorised.delete(id);
|
||||
channels.push(addChannel(id));
|
||||
}
|
||||
if (server.categories) {
|
||||
for (let category of server.categories) {
|
||||
let channels = [];
|
||||
for (let id of category.channels) {
|
||||
uncategorised.delete(id);
|
||||
channels.push(addChannel(id));
|
||||
}
|
||||
|
||||
elements.push(
|
||||
<CollapsibleSection
|
||||
id={`category_${category.id}`}
|
||||
defaultValue
|
||||
summary={<Category text={category.title} />}>
|
||||
{channels}
|
||||
</CollapsibleSection>,
|
||||
);
|
||||
}
|
||||
}
|
||||
elements.push(
|
||||
<CollapsibleSection
|
||||
id={`category_${category.id}`}
|
||||
defaultValue
|
||||
summary={<Category text={category.title} />}>
|
||||
{channels}
|
||||
</CollapsibleSection>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (let id of Array.from(uncategorised).reverse()) {
|
||||
elements.unshift(addChannel(id));
|
||||
}
|
||||
for (let id of Array.from(uncategorised).reverse()) {
|
||||
elements.unshift(addChannel(id));
|
||||
}
|
||||
|
||||
return (
|
||||
<ServerBase>
|
||||
<ServerHeader server={server} ctx={ctx} />
|
||||
<ConnectionStatus />
|
||||
<ServerList
|
||||
onContextMenu={attachContextMenu("Menu", {
|
||||
server_list: server._id,
|
||||
})}>
|
||||
{elements}
|
||||
</ServerList>
|
||||
<PaintCounter small />
|
||||
</ServerBase>
|
||||
);
|
||||
return (
|
||||
<ServerBase>
|
||||
<ServerHeader server={server} ctx={ctx} />
|
||||
<ConnectionStatus />
|
||||
<ServerList
|
||||
onContextMenu={attachContextMenu("Menu", {
|
||||
server_list: server._id,
|
||||
})}>
|
||||
{elements}
|
||||
</ServerList>
|
||||
<PaintCounter small />
|
||||
</ServerBase>
|
||||
);
|
||||
}
|
||||
|
||||
export default connectState(ServerSidebar, (state) => {
|
||||
return {
|
||||
unreads: state.unreads,
|
||||
};
|
||||
return {
|
||||
unreads: state.unreads,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -8,96 +8,96 @@ import { Unreads } from "../../../redux/reducers/unreads";
|
||||
import { HookContext, useForceUpdate } from "../../../context/revoltjs/hooks";
|
||||
|
||||
type UnreadProps = {
|
||||
channel: Channel;
|
||||
unreads: Unreads;
|
||||
channel: Channel;
|
||||
unreads: Unreads;
|
||||
};
|
||||
|
||||
export function useUnreads(
|
||||
{ channel, unreads }: UnreadProps,
|
||||
context?: HookContext,
|
||||
{ channel, unreads }: UnreadProps,
|
||||
context?: HookContext,
|
||||
) {
|
||||
const ctx = useForceUpdate(context);
|
||||
const ctx = useForceUpdate(context);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
function checkUnread(target?: Channel) {
|
||||
if (!target) return;
|
||||
if (target._id !== channel._id) return;
|
||||
if (
|
||||
target.channel_type === "SavedMessages" ||
|
||||
target.channel_type === "VoiceChannel"
|
||||
)
|
||||
return;
|
||||
useLayoutEffect(() => {
|
||||
function checkUnread(target?: Channel) {
|
||||
if (!target) return;
|
||||
if (target._id !== channel._id) return;
|
||||
if (
|
||||
target.channel_type === "SavedMessages" ||
|
||||
target.channel_type === "VoiceChannel"
|
||||
)
|
||||
return;
|
||||
|
||||
const unread = unreads[channel._id]?.last_id;
|
||||
if (target.last_message) {
|
||||
const message =
|
||||
typeof target.last_message === "string"
|
||||
? target.last_message
|
||||
: target.last_message._id;
|
||||
if (!unread || (unread && message.localeCompare(unread) > 0)) {
|
||||
dispatch({
|
||||
type: "UNREADS_MARK_READ",
|
||||
channel: channel._id,
|
||||
message,
|
||||
});
|
||||
const unread = unreads[channel._id]?.last_id;
|
||||
if (target.last_message) {
|
||||
const message =
|
||||
typeof target.last_message === "string"
|
||||
? target.last_message
|
||||
: target.last_message._id;
|
||||
if (!unread || (unread && message.localeCompare(unread) > 0)) {
|
||||
dispatch({
|
||||
type: "UNREADS_MARK_READ",
|
||||
channel: channel._id,
|
||||
message,
|
||||
});
|
||||
|
||||
ctx.client.req(
|
||||
"PUT",
|
||||
`/channels/${channel._id}/ack/${message}` as "/channels/id/ack/id",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.client.req(
|
||||
"PUT",
|
||||
`/channels/${channel._id}/ack/${message}` as "/channels/id/ack/id",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
checkUnread(channel);
|
||||
checkUnread(channel);
|
||||
|
||||
ctx.client.channels.addListener("mutation", checkUnread);
|
||||
return () =>
|
||||
ctx.client.channels.removeListener("mutation", checkUnread);
|
||||
}, [channel, unreads]);
|
||||
ctx.client.channels.addListener("mutation", checkUnread);
|
||||
return () =>
|
||||
ctx.client.channels.removeListener("mutation", checkUnread);
|
||||
}, [channel, unreads]);
|
||||
}
|
||||
|
||||
export function mapChannelWithUnread(channel: Channel, unreads: Unreads) {
|
||||
let last_message_id;
|
||||
if (
|
||||
channel.channel_type === "DirectMessage" ||
|
||||
channel.channel_type === "Group"
|
||||
) {
|
||||
last_message_id = channel.last_message?._id;
|
||||
} else if (channel.channel_type === "TextChannel") {
|
||||
last_message_id = channel.last_message;
|
||||
} else {
|
||||
return {
|
||||
...channel,
|
||||
unread: undefined,
|
||||
alertCount: undefined,
|
||||
timestamp: channel._id,
|
||||
};
|
||||
}
|
||||
let last_message_id;
|
||||
if (
|
||||
channel.channel_type === "DirectMessage" ||
|
||||
channel.channel_type === "Group"
|
||||
) {
|
||||
last_message_id = channel.last_message?._id;
|
||||
} else if (channel.channel_type === "TextChannel") {
|
||||
last_message_id = channel.last_message;
|
||||
} else {
|
||||
return {
|
||||
...channel,
|
||||
unread: undefined,
|
||||
alertCount: undefined,
|
||||
timestamp: channel._id,
|
||||
};
|
||||
}
|
||||
|
||||
let unread: "mention" | "unread" | undefined;
|
||||
let alertCount: undefined | number;
|
||||
if (last_message_id && unreads) {
|
||||
const u = unreads[channel._id];
|
||||
if (u) {
|
||||
if (u.mentions && u.mentions.length > 0) {
|
||||
alertCount = u.mentions.length;
|
||||
unread = "mention";
|
||||
} else if (
|
||||
u.last_id &&
|
||||
last_message_id.localeCompare(u.last_id) > 0
|
||||
) {
|
||||
unread = "unread";
|
||||
}
|
||||
} else {
|
||||
unread = "unread";
|
||||
}
|
||||
}
|
||||
let unread: "mention" | "unread" | undefined;
|
||||
let alertCount: undefined | number;
|
||||
if (last_message_id && unreads) {
|
||||
const u = unreads[channel._id];
|
||||
if (u) {
|
||||
if (u.mentions && u.mentions.length > 0) {
|
||||
alertCount = u.mentions.length;
|
||||
unread = "mention";
|
||||
} else if (
|
||||
u.last_id &&
|
||||
last_message_id.localeCompare(u.last_id) > 0
|
||||
) {
|
||||
unread = "unread";
|
||||
}
|
||||
} else {
|
||||
unread = "unread";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...channel,
|
||||
timestamp: last_message_id ?? channel._id,
|
||||
unread,
|
||||
alertCount,
|
||||
};
|
||||
return {
|
||||
...channel,
|
||||
timestamp: last_message_id ?? channel._id,
|
||||
unread,
|
||||
alertCount,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
import { useRenderState } from "../../../lib/renderer/Singleton";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export function ChannelDebugInfo({ id }: Props) {
|
||||
if (process.env.NODE_ENV !== "development") return null;
|
||||
let view = useRenderState(id);
|
||||
if (!view) return null;
|
||||
if (process.env.NODE_ENV !== "development") return null;
|
||||
let view = useRenderState(id);
|
||||
if (!view) return null;
|
||||
|
||||
return (
|
||||
<span style={{ display: "block", padding: "12px 10px 0 10px" }}>
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: "12px",
|
||||
textTransform: "uppercase",
|
||||
fontWeight: "600",
|
||||
}}>
|
||||
Channel Info
|
||||
</span>
|
||||
<p style={{ fontSize: "10px", userSelect: "text" }}>
|
||||
State: <b>{view.type}</b> <br />
|
||||
{view.type === "RENDER" && view.messages.length > 0 && (
|
||||
<>
|
||||
Start: <b>{view.messages[0]._id}</b> <br />
|
||||
End:{" "}
|
||||
<b>
|
||||
{view.messages[view.messages.length - 1]._id}
|
||||
</b>{" "}
|
||||
<br />
|
||||
At Top: <b>{view.atTop ? "Yes" : "No"}</b> <br />
|
||||
At Bottom: <b>{view.atBottom ? "Yes" : "No"}</b>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<span style={{ display: "block", padding: "12px 10px 0 10px" }}>
|
||||
<span
|
||||
style={{
|
||||
display: "block",
|
||||
fontSize: "12px",
|
||||
textTransform: "uppercase",
|
||||
fontWeight: "600",
|
||||
}}>
|
||||
Channel Info
|
||||
</span>
|
||||
<p style={{ fontSize: "10px", userSelect: "text" }}>
|
||||
State: <b>{view.type}</b> <br />
|
||||
{view.type === "RENDER" && view.messages.length > 0 && (
|
||||
<>
|
||||
Start: <b>{view.messages[0]._id}</b> <br />
|
||||
End:{" "}
|
||||
<b>
|
||||
{view.messages[view.messages.length - 1]._id}
|
||||
</b>{" "}
|
||||
<br />
|
||||
At Top: <b>{view.atTop ? "Yes" : "No"}</b> <br />
|
||||
At Bottom: <b>{view.atBottom ? "Yes" : "No"}</b>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,15 +8,15 @@ import { useContext, useEffect, useState } from "preact/hooks";
|
||||
|
||||
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
||||
import {
|
||||
AppContext,
|
||||
ClientStatus,
|
||||
StatusContext,
|
||||
AppContext,
|
||||
ClientStatus,
|
||||
StatusContext,
|
||||
} from "../../../context/revoltjs/RevoltClient";
|
||||
import {
|
||||
HookContext,
|
||||
useChannel,
|
||||
useForceUpdate,
|
||||
useUsers,
|
||||
HookContext,
|
||||
useChannel,
|
||||
useForceUpdate,
|
||||
useUsers,
|
||||
} from "../../../context/revoltjs/hooks";
|
||||
|
||||
import Category from "../../ui/Category";
|
||||
@@ -28,35 +28,35 @@ import { UserButton } from "../items/ButtonItem";
|
||||
import { ChannelDebugInfo } from "./ChannelDebugInfo";
|
||||
|
||||
interface Props {
|
||||
ctx: HookContext;
|
||||
ctx: HookContext;
|
||||
}
|
||||
|
||||
export default function MemberSidebar(props: { channel?: Channels.Channel }) {
|
||||
const ctx = useForceUpdate();
|
||||
const { channel: cid } = useParams<{ channel: string }>();
|
||||
const channel = props.channel ?? useChannel(cid, ctx);
|
||||
const ctx = useForceUpdate();
|
||||
const { channel: cid } = useParams<{ channel: string }>();
|
||||
const channel = props.channel ?? useChannel(cid, ctx);
|
||||
|
||||
switch (channel?.channel_type) {
|
||||
case "Group":
|
||||
return <GroupMemberSidebar channel={channel} ctx={ctx} />;
|
||||
case "TextChannel":
|
||||
return <ServerMemberSidebar channel={channel} ctx={ctx} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
switch (channel?.channel_type) {
|
||||
case "Group":
|
||||
return <GroupMemberSidebar channel={channel} ctx={ctx} />;
|
||||
case "TextChannel":
|
||||
return <ServerMemberSidebar channel={channel} ctx={ctx} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function GroupMemberSidebar({
|
||||
channel,
|
||||
ctx,
|
||||
channel,
|
||||
ctx,
|
||||
}: Props & { channel: Channels.GroupChannel }) {
|
||||
const { openScreen } = useIntermediate();
|
||||
const users = useUsers(undefined, ctx);
|
||||
let members = channel.recipients
|
||||
.map((x) => users.find((y) => y?._id === x))
|
||||
.filter((x) => typeof x !== "undefined") as User[];
|
||||
const { openScreen } = useIntermediate();
|
||||
const users = useUsers(undefined, ctx);
|
||||
let members = channel.recipients
|
||||
.map((x) => users.find((y) => y?._id === x))
|
||||
.filter((x) => typeof x !== "undefined") as User[];
|
||||
|
||||
/*const voice = useContext(VoiceContext);
|
||||
/*const voice = useContext(VoiceContext);
|
||||
const voiceActive = voice.roomId === channel._id;
|
||||
|
||||
let voiceParticipants: User[] = [];
|
||||
@@ -71,32 +71,32 @@ export function GroupMemberSidebar({
|
||||
voiceParticipants.sort((a, b) => a.username.localeCompare(b.username));
|
||||
}*/
|
||||
|
||||
members.sort((a, b) => {
|
||||
// ! FIXME: should probably rewrite all this code
|
||||
let l =
|
||||
+(
|
||||
(a.online && a.status?.presence !== Users.Presence.Invisible) ??
|
||||
false
|
||||
) | 0;
|
||||
let r =
|
||||
+(
|
||||
(b.online && b.status?.presence !== Users.Presence.Invisible) ??
|
||||
false
|
||||
) | 0;
|
||||
members.sort((a, b) => {
|
||||
// ! FIXME: should probably rewrite all this code
|
||||
let l =
|
||||
+(
|
||||
(a.online && a.status?.presence !== Users.Presence.Invisible) ??
|
||||
false
|
||||
) | 0;
|
||||
let r =
|
||||
+(
|
||||
(b.online && b.status?.presence !== Users.Presence.Invisible) ??
|
||||
false
|
||||
) | 0;
|
||||
|
||||
let n = r - l;
|
||||
if (n !== 0) {
|
||||
return n;
|
||||
}
|
||||
let n = r - l;
|
||||
if (n !== 0) {
|
||||
return n;
|
||||
}
|
||||
|
||||
return a.username.localeCompare(b.username);
|
||||
});
|
||||
return a.username.localeCompare(b.username);
|
||||
});
|
||||
|
||||
return (
|
||||
<GenericSidebarBase>
|
||||
<GenericSidebarList>
|
||||
<ChannelDebugInfo id={channel._id} />
|
||||
{/*voiceActive && voiceParticipants.length !== 0 && (
|
||||
return (
|
||||
<GenericSidebarBase>
|
||||
<GenericSidebarList>
|
||||
<ChannelDebugInfo id={channel._id} />
|
||||
{/*voiceActive && voiceParticipants.length !== 0 && (
|
||||
<Fragment>
|
||||
<Category
|
||||
type="members"
|
||||
@@ -121,146 +121,146 @@ export function GroupMemberSidebar({
|
||||
)}
|
||||
</Fragment>
|
||||
)*/}
|
||||
{!((members.length === 0) /*&& voiceActive*/) && (
|
||||
<Category
|
||||
variant="uniform"
|
||||
text={
|
||||
<span>
|
||||
<Text id="app.main.categories.members" /> —{" "}
|
||||
{channel.recipients.length}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{members.length === 0 && (
|
||||
/*!voiceActive &&*/ <img src={placeholderSVG} />
|
||||
)}
|
||||
{members.map(
|
||||
(user) =>
|
||||
user && (
|
||||
<UserButton
|
||||
key={user._id}
|
||||
user={user}
|
||||
context={channel}
|
||||
onClick={() =>
|
||||
openScreen({
|
||||
id: "profile",
|
||||
user_id: user._id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</GenericSidebarList>
|
||||
</GenericSidebarBase>
|
||||
);
|
||||
{!((members.length === 0) /*&& voiceActive*/) && (
|
||||
<Category
|
||||
variant="uniform"
|
||||
text={
|
||||
<span>
|
||||
<Text id="app.main.categories.members" /> —{" "}
|
||||
{channel.recipients.length}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{members.length === 0 && (
|
||||
/*!voiceActive &&*/ <img src={placeholderSVG} />
|
||||
)}
|
||||
{members.map(
|
||||
(user) =>
|
||||
user && (
|
||||
<UserButton
|
||||
key={user._id}
|
||||
user={user}
|
||||
context={channel}
|
||||
onClick={() =>
|
||||
openScreen({
|
||||
id: "profile",
|
||||
user_id: user._id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</GenericSidebarList>
|
||||
</GenericSidebarBase>
|
||||
);
|
||||
}
|
||||
|
||||
export function ServerMemberSidebar({
|
||||
channel,
|
||||
ctx,
|
||||
channel,
|
||||
ctx,
|
||||
}: Props & { channel: Channels.TextChannel }) {
|
||||
const [members, setMembers] = useState<Servers.Member[] | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const users = useUsers(members?.map((x) => x._id.user) ?? []).filter(
|
||||
(x) => typeof x !== "undefined",
|
||||
ctx,
|
||||
) as Users.User[];
|
||||
const { openScreen } = useIntermediate();
|
||||
const status = useContext(StatusContext);
|
||||
const client = useContext(AppContext);
|
||||
const [members, setMembers] = useState<Servers.Member[] | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const users = useUsers(members?.map((x) => x._id.user) ?? []).filter(
|
||||
(x) => typeof x !== "undefined",
|
||||
ctx,
|
||||
) as Users.User[];
|
||||
const { openScreen } = useIntermediate();
|
||||
const status = useContext(StatusContext);
|
||||
const client = useContext(AppContext);
|
||||
|
||||
useEffect(() => {
|
||||
if (status === ClientStatus.ONLINE && typeof members === "undefined") {
|
||||
client.servers.members
|
||||
.fetchMembers(channel.server)
|
||||
.then((members) => setMembers(members));
|
||||
}
|
||||
}, [status]);
|
||||
useEffect(() => {
|
||||
if (status === ClientStatus.ONLINE && typeof members === "undefined") {
|
||||
client.servers.members
|
||||
.fetchMembers(channel.server)
|
||||
.then((members) => setMembers(members));
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
// ! FIXME: temporary code
|
||||
useEffect(() => {
|
||||
function onPacket(packet: ClientboundNotification) {
|
||||
if (!members) return;
|
||||
if (packet.type === "ServerMemberJoin") {
|
||||
if (packet.id !== channel.server) return;
|
||||
setMembers([
|
||||
...members,
|
||||
{ _id: { server: packet.id, user: packet.user } },
|
||||
]);
|
||||
} else if (packet.type === "ServerMemberLeave") {
|
||||
if (packet.id !== channel.server) return;
|
||||
setMembers(
|
||||
members.filter(
|
||||
(x) =>
|
||||
!(
|
||||
x._id.user === packet.user &&
|
||||
x._id.server === packet.id
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
// ! FIXME: temporary code
|
||||
useEffect(() => {
|
||||
function onPacket(packet: ClientboundNotification) {
|
||||
if (!members) return;
|
||||
if (packet.type === "ServerMemberJoin") {
|
||||
if (packet.id !== channel.server) return;
|
||||
setMembers([
|
||||
...members,
|
||||
{ _id: { server: packet.id, user: packet.user } },
|
||||
]);
|
||||
} else if (packet.type === "ServerMemberLeave") {
|
||||
if (packet.id !== channel.server) return;
|
||||
setMembers(
|
||||
members.filter(
|
||||
(x) =>
|
||||
!(
|
||||
x._id.user === packet.user &&
|
||||
x._id.server === packet.id
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
client.addListener("packet", onPacket);
|
||||
return () => client.removeListener("packet", onPacket);
|
||||
}, [members]);
|
||||
client.addListener("packet", onPacket);
|
||||
return () => client.removeListener("packet", onPacket);
|
||||
}, [members]);
|
||||
|
||||
// copy paste from above
|
||||
users.sort((a, b) => {
|
||||
// ! FIXME: should probably rewrite all this code
|
||||
let l =
|
||||
+(
|
||||
(a.online && a.status?.presence !== Users.Presence.Invisible) ??
|
||||
false
|
||||
) | 0;
|
||||
let r =
|
||||
+(
|
||||
(b.online && b.status?.presence !== Users.Presence.Invisible) ??
|
||||
false
|
||||
) | 0;
|
||||
// copy paste from above
|
||||
users.sort((a, b) => {
|
||||
// ! FIXME: should probably rewrite all this code
|
||||
let l =
|
||||
+(
|
||||
(a.online && a.status?.presence !== Users.Presence.Invisible) ??
|
||||
false
|
||||
) | 0;
|
||||
let r =
|
||||
+(
|
||||
(b.online && b.status?.presence !== Users.Presence.Invisible) ??
|
||||
false
|
||||
) | 0;
|
||||
|
||||
let n = r - l;
|
||||
if (n !== 0) {
|
||||
return n;
|
||||
}
|
||||
let n = r - l;
|
||||
if (n !== 0) {
|
||||
return n;
|
||||
}
|
||||
|
||||
return a.username.localeCompare(b.username);
|
||||
});
|
||||
return a.username.localeCompare(b.username);
|
||||
});
|
||||
|
||||
return (
|
||||
<GenericSidebarBase>
|
||||
<GenericSidebarList>
|
||||
<ChannelDebugInfo id={channel._id} />
|
||||
<Category
|
||||
variant="uniform"
|
||||
text={
|
||||
<span>
|
||||
<Text id="app.main.categories.members" /> —{" "}
|
||||
{users.length}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{!members && <Preloader type="ring" />}
|
||||
{members && users.length === 0 && <img src={placeholderSVG} />}
|
||||
{users.map(
|
||||
(user) =>
|
||||
user && (
|
||||
<UserButton
|
||||
key={user._id}
|
||||
user={user}
|
||||
context={channel}
|
||||
onClick={() =>
|
||||
openScreen({
|
||||
id: "profile",
|
||||
user_id: user._id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</GenericSidebarList>
|
||||
</GenericSidebarBase>
|
||||
);
|
||||
return (
|
||||
<GenericSidebarBase>
|
||||
<GenericSidebarList>
|
||||
<ChannelDebugInfo id={channel._id} />
|
||||
<Category
|
||||
variant="uniform"
|
||||
text={
|
||||
<span>
|
||||
<Text id="app.main.categories.members" /> —{" "}
|
||||
{users.length}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{!members && <Preloader type="ring" />}
|
||||
{members && users.length === 0 && <img src={placeholderSVG} />}
|
||||
{users.map(
|
||||
(user) =>
|
||||
user && (
|
||||
<UserButton
|
||||
key={user._id}
|
||||
user={user}
|
||||
context={channel}
|
||||
onClick={() =>
|
||||
openScreen({
|
||||
id: "profile",
|
||||
user_id: user._id,
|
||||
})
|
||||
}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</GenericSidebarList>
|
||||
</GenericSidebarBase>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
export default styled.div`
|
||||
padding: 8px;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
padding: 8px;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
|
||||
color: var(--accent);
|
||||
background: var(--primary-background);
|
||||
color: var(--accent);
|
||||
background: var(--primary-background);
|
||||
`;
|
||||
|
||||
@@ -1,71 +1,71 @@
|
||||
import styled, { css } from "styled-components";
|
||||
|
||||
interface Props {
|
||||
readonly contrast?: boolean;
|
||||
readonly error?: boolean;
|
||||
readonly contrast?: boolean;
|
||||
readonly error?: boolean;
|
||||
}
|
||||
|
||||
export default styled.button<Props>`
|
||||
z-index: 1;
|
||||
padding: 8px;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
font-family: inherit;
|
||||
z-index: 1;
|
||||
padding: 8px;
|
||||
font-size: 16px;
|
||||
text-align: center;
|
||||
font-family: inherit;
|
||||
|
||||
transition: 0.2s ease opacity;
|
||||
transition: 0.2s ease background-color;
|
||||
transition: 0.2s ease opacity;
|
||||
transition: 0.2s ease background-color;
|
||||
|
||||
background: var(--primary-background);
|
||||
color: var(--foreground);
|
||||
background: var(--primary-background);
|
||||
color: var(--foreground);
|
||||
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
|
||||
&:hover {
|
||||
background: var(--secondary-header);
|
||||
}
|
||||
&:hover {
|
||||
background: var(--secondary-header);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: var(--primary-background);
|
||||
}
|
||||
&:disabled {
|
||||
background: var(--primary-background);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--secondary-background);
|
||||
}
|
||||
&:active {
|
||||
background: var(--secondary-background);
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.contrast &&
|
||||
css`
|
||||
padding: 4px 8px;
|
||||
background: var(--secondary-header);
|
||||
${(props) =>
|
||||
props.contrast &&
|
||||
css`
|
||||
padding: 4px 8px;
|
||||
background: var(--secondary-header);
|
||||
|
||||
&:hover {
|
||||
background: var(--primary-header);
|
||||
}
|
||||
&:hover {
|
||||
background: var(--primary-header);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: var(--secondary-header);
|
||||
}
|
||||
&:disabled {
|
||||
background: var(--secondary-header);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--secondary-background);
|
||||
}
|
||||
`}
|
||||
&:active {
|
||||
background: var(--secondary-background);
|
||||
}
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.error &&
|
||||
css`
|
||||
color: white;
|
||||
background: var(--error);
|
||||
${(props) =>
|
||||
props.error &&
|
||||
css`
|
||||
color: white;
|
||||
background: var(--error);
|
||||
|
||||
&:hover {
|
||||
filter: brightness(1.2);
|
||||
background: var(--error);
|
||||
}
|
||||
&:hover {
|
||||
filter: brightness(1.2);
|
||||
background: var(--error);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
background: var(--error);
|
||||
}
|
||||
`}
|
||||
&:disabled {
|
||||
background: var(--error);
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
@@ -4,52 +4,52 @@ import styled, { css } from "styled-components";
|
||||
import { Children } from "../../types/Preact";
|
||||
|
||||
const CategoryBase = styled.div<Pick<Props, "variant">>`
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
|
||||
margin-top: 4px;
|
||||
padding: 6px 0;
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
margin-top: 4px;
|
||||
padding: 6px 0;
|
||||
margin-bottom: 4px;
|
||||
white-space: nowrap;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
|
||||
svg {
|
||||
cursor: pointer;
|
||||
}
|
||||
svg {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.variant === "uniform" &&
|
||||
css`
|
||||
padding-top: 6px;
|
||||
`}
|
||||
${(props) =>
|
||||
props.variant === "uniform" &&
|
||||
css`
|
||||
padding-top: 6px;
|
||||
`}
|
||||
`;
|
||||
|
||||
type Props = Omit<
|
||||
JSX.HTMLAttributes<HTMLDivElement>,
|
||||
"children" | "as" | "action"
|
||||
JSX.HTMLAttributes<HTMLDivElement>,
|
||||
"children" | "as" | "action"
|
||||
> & {
|
||||
text: Children;
|
||||
action?: () => void;
|
||||
variant?: "default" | "uniform";
|
||||
text: Children;
|
||||
action?: () => void;
|
||||
variant?: "default" | "uniform";
|
||||
};
|
||||
|
||||
export default function Category(props: Props) {
|
||||
let { text, action, ...otherProps } = props;
|
||||
let { text, action, ...otherProps } = props;
|
||||
|
||||
return (
|
||||
<CategoryBase {...otherProps}>
|
||||
{text}
|
||||
{action && <Plus size={16} onClick={action} />}
|
||||
</CategoryBase>
|
||||
);
|
||||
return (
|
||||
<CategoryBase {...otherProps}>
|
||||
{text}
|
||||
{action && <Plus size={16} onClick={action} />}
|
||||
</CategoryBase>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,105 +4,105 @@ import styled, { css } from "styled-components";
|
||||
import { Children } from "../../types/Preact";
|
||||
|
||||
const CheckboxBase = styled.label`
|
||||
margin-top: 20px;
|
||||
gap: 4px;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
border-radius: 4px;
|
||||
align-items: center;
|
||||
margin-top: 20px;
|
||||
gap: 4px;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
border-radius: 4px;
|
||||
align-items: center;
|
||||
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
user-select: none;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
user-select: none;
|
||||
|
||||
transition: 0.2s ease all;
|
||||
transition: 0.2s ease all;
|
||||
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.check {
|
||||
background: var(--background);
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
.check {
|
||||
background: var(--background);
|
||||
}
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
&[disabled] {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
|
||||
&:hover {
|
||||
background: unset;
|
||||
}
|
||||
}
|
||||
&:hover {
|
||||
background: unset;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const CheckboxContent = styled.span`
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
flex-direction: column;
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const CheckboxDescription = styled.span`
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
color: var(--secondary-foreground);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
color: var(--secondary-foreground);
|
||||
`;
|
||||
|
||||
const Checkmark = styled.div<{ checked: boolean }>`
|
||||
margin: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
place-items: center;
|
||||
transition: 0.2s ease all;
|
||||
background: var(--secondary-background);
|
||||
margin: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
flex-shrink: 0;
|
||||
border-radius: 4px;
|
||||
place-items: center;
|
||||
transition: 0.2s ease all;
|
||||
background: var(--secondary-background);
|
||||
|
||||
svg {
|
||||
color: var(--secondary-background);
|
||||
}
|
||||
svg {
|
||||
color: var(--secondary-background);
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.checked &&
|
||||
css`
|
||||
background: var(--accent) !important;
|
||||
`}
|
||||
${(props) =>
|
||||
props.checked &&
|
||||
css`
|
||||
background: var(--accent) !important;
|
||||
`}
|
||||
`;
|
||||
|
||||
export interface CheckboxProps {
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
children: Children;
|
||||
description?: Children;
|
||||
onChange: (state: boolean) => void;
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
children: Children;
|
||||
description?: Children;
|
||||
onChange: (state: boolean) => void;
|
||||
}
|
||||
|
||||
export default function Checkbox(props: CheckboxProps) {
|
||||
return (
|
||||
<CheckboxBase disabled={props.disabled} className={props.className}>
|
||||
<CheckboxContent>
|
||||
<span>{props.children}</span>
|
||||
{props.description && (
|
||||
<CheckboxDescription>
|
||||
{props.description}
|
||||
</CheckboxDescription>
|
||||
)}
|
||||
</CheckboxContent>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.checked}
|
||||
onChange={() =>
|
||||
!props.disabled && props.onChange(!props.checked)
|
||||
}
|
||||
/>
|
||||
<Checkmark checked={props.checked} className="check">
|
||||
<Check size={20} />
|
||||
</Checkmark>
|
||||
</CheckboxBase>
|
||||
);
|
||||
return (
|
||||
<CheckboxBase disabled={props.disabled} className={props.className}>
|
||||
<CheckboxContent>
|
||||
<span>{props.children}</span>
|
||||
{props.description && (
|
||||
<CheckboxDescription>
|
||||
{props.description}
|
||||
</CheckboxDescription>
|
||||
)}
|
||||
</CheckboxContent>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.checked}
|
||||
onChange={() =>
|
||||
!props.disabled && props.onChange(!props.checked)
|
||||
}
|
||||
/>
|
||||
<Checkmark checked={props.checked} className="check">
|
||||
<Check size={20} />
|
||||
</Checkmark>
|
||||
</CheckboxBase>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,123 +5,123 @@ import styled, { css } from "styled-components";
|
||||
import { useRef } from "preact/hooks";
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
const presets = [
|
||||
[
|
||||
"#7B68EE",
|
||||
"#3498DB",
|
||||
"#1ABC9C",
|
||||
"#F1C40F",
|
||||
"#FF7F50",
|
||||
"#FD6671",
|
||||
"#E91E63",
|
||||
"#D468EE",
|
||||
],
|
||||
[
|
||||
"#594CAD",
|
||||
"#206694",
|
||||
"#11806A",
|
||||
"#C27C0E",
|
||||
"#CD5B45",
|
||||
"#FF424F",
|
||||
"#AD1457",
|
||||
"#954AA8",
|
||||
],
|
||||
[
|
||||
"#7B68EE",
|
||||
"#3498DB",
|
||||
"#1ABC9C",
|
||||
"#F1C40F",
|
||||
"#FF7F50",
|
||||
"#FD6671",
|
||||
"#E91E63",
|
||||
"#D468EE",
|
||||
],
|
||||
[
|
||||
"#594CAD",
|
||||
"#206694",
|
||||
"#11806A",
|
||||
"#C27C0E",
|
||||
"#CD5B45",
|
||||
"#FF424F",
|
||||
"#AD1457",
|
||||
"#954AA8",
|
||||
],
|
||||
];
|
||||
|
||||
const SwatchesBase = styled.div`
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
|
||||
input {
|
||||
opacity: 0;
|
||||
margin-top: 44px;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
input {
|
||||
opacity: 0;
|
||||
margin-top: 44px;
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
|
||||
const Swatch = styled.div<{ type: "small" | "large"; colour: string }>`
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
background-color: ${(props) => props.colour};
|
||||
flex-shrink: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
background-color: ${(props) => props.colour};
|
||||
|
||||
display: grid;
|
||||
place-items: center;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
|
||||
&:hover {
|
||||
border: 3px solid var(--foreground);
|
||||
transition: border ease-in-out 0.07s;
|
||||
}
|
||||
&:hover {
|
||||
border: 3px solid var(--foreground);
|
||||
transition: border ease-in-out 0.07s;
|
||||
}
|
||||
|
||||
svg {
|
||||
color: white;
|
||||
}
|
||||
svg {
|
||||
color: white;
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.type === "small"
|
||||
? css`
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
${(props) =>
|
||||
props.type === "small"
|
||||
? css`
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
|
||||
svg {
|
||||
/*stroke-width: 2;*/
|
||||
}
|
||||
`
|
||||
: css`
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
`}
|
||||
svg {
|
||||
/*stroke-width: 2;*/
|
||||
}
|
||||
`
|
||||
: css`
|
||||
width: 68px;
|
||||
height: 68px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const Rows = styled.div`
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
> div {
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
> div {
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
`;
|
||||
|
||||
export default function ColourSwatches({ value, onChange }: Props) {
|
||||
const ref = useRef<HTMLInputElement>();
|
||||
const ref = useRef<HTMLInputElement>();
|
||||
|
||||
return (
|
||||
<SwatchesBase>
|
||||
<Swatch
|
||||
colour={value}
|
||||
type="large"
|
||||
onClick={() => ref.current.click()}>
|
||||
<Palette size={32} />
|
||||
</Swatch>
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
ref={ref}
|
||||
onChange={(ev) => onChange(ev.currentTarget.value)}
|
||||
/>
|
||||
<Rows>
|
||||
{presets.map((row, i) => (
|
||||
<div key={i}>
|
||||
{row.map((swatch, i) => (
|
||||
<Swatch
|
||||
colour={swatch}
|
||||
type="small"
|
||||
key={i}
|
||||
onClick={() => onChange(swatch)}>
|
||||
{swatch === value && <Check size={18} />}
|
||||
</Swatch>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</Rows>
|
||||
</SwatchesBase>
|
||||
);
|
||||
return (
|
||||
<SwatchesBase>
|
||||
<Swatch
|
||||
colour={value}
|
||||
type="large"
|
||||
onClick={() => ref.current.click()}>
|
||||
<Palette size={32} />
|
||||
</Swatch>
|
||||
<input
|
||||
type="color"
|
||||
value={value}
|
||||
ref={ref}
|
||||
onChange={(ev) => onChange(ev.currentTarget.value)}
|
||||
/>
|
||||
<Rows>
|
||||
{presets.map((row, i) => (
|
||||
<div key={i}>
|
||||
{row.map((swatch, i) => (
|
||||
<Swatch
|
||||
colour={swatch}
|
||||
type="small"
|
||||
key={i}
|
||||
onClick={() => onChange(swatch)}>
|
||||
{swatch === value && <Check size={18} />}
|
||||
</Swatch>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</Rows>
|
||||
</SwatchesBase>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
export default styled.select`
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
color: var(--secondary-foreground);
|
||||
background: var(--secondary-background);
|
||||
font-size: 0.875rem;
|
||||
border: none;
|
||||
outline: 2px solid transparent;
|
||||
transition: outline-color 0.2s ease-in-out;
|
||||
transition: box-shadow 0.3s;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
color: var(--secondary-foreground);
|
||||
background: var(--secondary-background);
|
||||
font-size: 0.875rem;
|
||||
border: none;
|
||||
outline: 2px solid transparent;
|
||||
transition: outline-color 0.2s ease-in-out;
|
||||
transition: box-shadow 0.3s;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 2pt var(--accent);
|
||||
}
|
||||
&:focus {
|
||||
box-shadow: 0 0 0 2pt var(--accent);
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -2,47 +2,47 @@ import dayjs from "dayjs";
|
||||
import styled, { css } from "styled-components";
|
||||
|
||||
const Base = styled.div<{ unread?: boolean }>`
|
||||
height: 0;
|
||||
display: flex;
|
||||
user-select: none;
|
||||
align-items: center;
|
||||
margin: 17px 12px 5px;
|
||||
border-top: thin solid var(--tertiary-foreground);
|
||||
height: 0;
|
||||
display: flex;
|
||||
user-select: none;
|
||||
align-items: center;
|
||||
margin: 17px 12px 5px;
|
||||
border-top: thin solid var(--tertiary-foreground);
|
||||
|
||||
time {
|
||||
margin-top: -2px;
|
||||
font-size: 0.6875rem;
|
||||
line-height: 0.6875rem;
|
||||
padding: 2px 5px 2px 0;
|
||||
color: var(--tertiary-foreground);
|
||||
background: var(--primary-background);
|
||||
}
|
||||
time {
|
||||
margin-top: -2px;
|
||||
font-size: 0.6875rem;
|
||||
line-height: 0.6875rem;
|
||||
padding: 2px 5px 2px 0;
|
||||
color: var(--tertiary-foreground);
|
||||
background: var(--primary-background);
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.unread &&
|
||||
css`
|
||||
border-top: thin solid var(--accent);
|
||||
`}
|
||||
${(props) =>
|
||||
props.unread &&
|
||||
css`
|
||||
border-top: thin solid var(--accent);
|
||||
`}
|
||||
`;
|
||||
|
||||
const Unread = styled.div`
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
padding: 5px 8px;
|
||||
border-radius: 60px;
|
||||
font-weight: 600;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
padding: 5px 8px;
|
||||
border-radius: 60px;
|
||||
font-weight: 600;
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
date: Date;
|
||||
unread?: boolean;
|
||||
date: Date;
|
||||
unread?: boolean;
|
||||
}
|
||||
|
||||
export default function DateDivider(props: Props) {
|
||||
return (
|
||||
<Base unread={props.unread}>
|
||||
{props.unread && <Unread>NEW</Unread>}
|
||||
<time>{dayjs(props.date).format("LL")}</time>
|
||||
</Base>
|
||||
);
|
||||
return (
|
||||
<Base unread={props.unread}>
|
||||
{props.unread && <Unread>NEW</Unread>}
|
||||
<time>{dayjs(props.date).format("LL")}</time>
|
||||
</Base>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,74 +1,74 @@
|
||||
import styled, { css } from "styled-components";
|
||||
|
||||
export default styled.details<{ sticky?: boolean; large?: boolean }>`
|
||||
summary {
|
||||
${(props) =>
|
||||
props.sticky &&
|
||||
css`
|
||||
top: -1px;
|
||||
z-index: 10;
|
||||
position: sticky;
|
||||
`}
|
||||
summary {
|
||||
${(props) =>
|
||||
props.sticky &&
|
||||
css`
|
||||
top: -1px;
|
||||
z-index: 10;
|
||||
position: sticky;
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.large &&
|
||||
css`
|
||||
/*padding: 5px 0;*/
|
||||
background: var(--primary-background);
|
||||
color: var(--secondary-foreground);
|
||||
${(props) =>
|
||||
props.large &&
|
||||
css`
|
||||
/*padding: 5px 0;*/
|
||||
background: var(--primary-background);
|
||||
color: var(--secondary-foreground);
|
||||
|
||||
.padding {
|
||||
/*TOFIX: make this applicable only for the friends list menu, DO NOT REMOVE.*/
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5px 0;
|
||||
margin: 0.8em 0px 0.4em;
|
||||
cursor: pointer;
|
||||
}
|
||||
`}
|
||||
.padding {
|
||||
/*TOFIX: make this applicable only for the friends list menu, DO NOT REMOVE.*/
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 5px 0;
|
||||
margin: 0.8em 0px 0.4em;
|
||||
cursor: pointer;
|
||||
}
|
||||
`}
|
||||
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
align-items: center;
|
||||
transition: 0.2s opacity;
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
align-items: center;
|
||||
transition: 0.2s opacity;
|
||||
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
|
||||
&::marker,
|
||||
&::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
&::marker,
|
||||
&::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex-grow: 1;
|
||||
margin-top: 1px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.title {
|
||||
flex-grow: 1;
|
||||
margin-top: 1px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.padding {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
.padding {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
> svg {
|
||||
flex-shrink: 0;
|
||||
margin-inline-end: 4px;
|
||||
transition: 0.2s ease transform;
|
||||
}
|
||||
}
|
||||
}
|
||||
> svg {
|
||||
flex-shrink: 0;
|
||||
margin-inline-end: 4px;
|
||||
transition: 0.2s ease transform;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&:not([open]) {
|
||||
summary {
|
||||
opacity: 0.7;
|
||||
}
|
||||
&:not([open]) {
|
||||
summary {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
summary svg {
|
||||
transform: rotateZ(-90deg);
|
||||
}
|
||||
}
|
||||
summary svg {
|
||||
transform: rotateZ(-90deg);
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1,57 +1,57 @@
|
||||
import styled, { css } from "styled-components";
|
||||
|
||||
interface Props {
|
||||
borders?: boolean;
|
||||
background?: boolean;
|
||||
placement: "primary" | "secondary";
|
||||
borders?: boolean;
|
||||
background?: boolean;
|
||||
placement: "primary" | "secondary";
|
||||
}
|
||||
|
||||
export default styled.div<Props>`
|
||||
gap: 6px;
|
||||
height: 48px;
|
||||
flex: 0 auto;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
padding: 0 16px;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 48px;
|
||||
flex: 0 auto;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
padding: 0 16px;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
align-items: center;
|
||||
|
||||
background-size: cover !important;
|
||||
background-position: center !important;
|
||||
background-color: var(--primary-header);
|
||||
background-size: cover !important;
|
||||
background-position: center !important;
|
||||
background-color: var(--primary-header);
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/*@media only screen and (max-width: 768px) {
|
||||
/*@media only screen and (max-width: 768px) {
|
||||
padding: 0 12px;
|
||||
}*/
|
||||
|
||||
@media (pointer: coarse) {
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.background &&
|
||||
css`
|
||||
height: 120px !important;
|
||||
align-items: flex-end;
|
||||
|
||||
text-shadow: 0px 0px 1px black;
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.placement === "secondary" &&
|
||||
css`
|
||||
background-color: var(--secondary-header);
|
||||
padding: 14px;
|
||||
`}
|
||||
@media (pointer: coarse) {
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.borders &&
|
||||
css`
|
||||
border-start-start-radius: 8px;
|
||||
`}
|
||||
props.background &&
|
||||
css`
|
||||
height: 120px !important;
|
||||
align-items: flex-end;
|
||||
|
||||
text-shadow: 0px 0px 1px black;
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.placement === "secondary" &&
|
||||
css`
|
||||
background-color: var(--secondary-header);
|
||||
padding: 14px;
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.borders &&
|
||||
css`
|
||||
border-start-start-radius: 8px;
|
||||
`}
|
||||
`;
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
import styled, { css } from "styled-components";
|
||||
|
||||
interface Props {
|
||||
type?: "default" | "circle";
|
||||
type?: "default" | "circle";
|
||||
}
|
||||
|
||||
const normal = `var(--secondary-foreground)`;
|
||||
const hover = `var(--foreground)`;
|
||||
|
||||
export default styled.div<Props>`
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
cursor: pointer;
|
||||
place-items: center;
|
||||
transition: 0.1s ease background-color;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
cursor: pointer;
|
||||
place-items: center;
|
||||
transition: 0.1s ease background-color;
|
||||
|
||||
fill: ${normal};
|
||||
color: ${normal};
|
||||
/*stroke: ${normal};*/
|
||||
fill: ${normal};
|
||||
color: ${normal};
|
||||
/*stroke: ${normal};*/
|
||||
|
||||
a {
|
||||
color: ${normal};
|
||||
}
|
||||
a {
|
||||
color: ${normal};
|
||||
}
|
||||
|
||||
&:hover {
|
||||
fill: ${hover};
|
||||
color: ${hover};
|
||||
/*stroke: ${hover};*/
|
||||
&:hover {
|
||||
fill: ${hover};
|
||||
color: ${hover};
|
||||
/*stroke: ${hover};*/
|
||||
|
||||
a {
|
||||
color: ${hover};
|
||||
}
|
||||
}
|
||||
a {
|
||||
color: ${hover};
|
||||
}
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.type === "circle" &&
|
||||
css`
|
||||
padding: 4px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--secondary-header);
|
||||
${(props) =>
|
||||
props.type === "circle" &&
|
||||
css`
|
||||
padding: 4px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--secondary-header);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--primary-header);
|
||||
}
|
||||
`}
|
||||
&:hover {
|
||||
background-color: var(--primary-header);
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
import styled, { css } from "styled-components";
|
||||
|
||||
interface Props {
|
||||
readonly contrast?: boolean;
|
||||
readonly contrast?: boolean;
|
||||
}
|
||||
|
||||
export default styled.input<Props>`
|
||||
z-index: 1;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
z-index: 1;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
|
||||
font-family: inherit;
|
||||
color: var(--foreground);
|
||||
background: var(--primary-background);
|
||||
transition: 0.2s ease background-color;
|
||||
font-family: inherit;
|
||||
color: var(--foreground);
|
||||
background: var(--primary-background);
|
||||
transition: 0.2s ease background-color;
|
||||
|
||||
border: none;
|
||||
outline: 2px solid transparent;
|
||||
transition: outline-color 0.2s ease-in-out;
|
||||
border: none;
|
||||
outline: 2px solid transparent;
|
||||
transition: outline-color 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background: var(--secondary-background);
|
||||
}
|
||||
&:hover {
|
||||
background: var(--secondary-background);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
}
|
||||
&:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.contrast &&
|
||||
css`
|
||||
color: var(--secondary-foreground);
|
||||
background: var(--secondary-background);
|
||||
${(props) =>
|
||||
props.contrast &&
|
||||
css`
|
||||
color: var(--secondary-foreground);
|
||||
background: var(--secondary-background);
|
||||
|
||||
&:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
`}
|
||||
&:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import styled from "styled-components";
|
||||
|
||||
export default styled.div`
|
||||
height: 0px;
|
||||
opacity: 0.6;
|
||||
flex-shrink: 0;
|
||||
margin: 8px 10px;
|
||||
border-top: 1px solid var(--tertiary-foreground);
|
||||
height: 0px;
|
||||
opacity: 0.6;
|
||||
flex-shrink: 0;
|
||||
margin: 8px 10px;
|
||||
border-top: 1px solid var(--tertiary-foreground);
|
||||
`;
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
// This file must be imported and used at least once for SVG masks.
|
||||
|
||||
export default function Masks() {
|
||||
return (
|
||||
<svg width={0} height={0} style={{ position: "fixed" }}>
|
||||
<defs>
|
||||
<mask id="server">
|
||||
<rect x="0" y="0" width="32" height="32" fill="white" />
|
||||
<circle cx="27" cy="5" r="7" fill={"black"} />
|
||||
</mask>
|
||||
<mask id="user">
|
||||
<rect x="0" y="0" width="32" height="32" fill="white" />
|
||||
<circle cx="27" cy="27" r="7" fill={"black"} />
|
||||
</mask>
|
||||
<mask id="overlap">
|
||||
<rect x="0" y="0" width="32" height="32" fill="white" />
|
||||
<circle cx="32" cy="16" r="18" fill={"black"} />
|
||||
</mask>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
return (
|
||||
<svg width={0} height={0} style={{ position: "fixed" }}>
|
||||
<defs>
|
||||
<mask id="server">
|
||||
<rect x="0" y="0" width="32" height="32" fill="white" />
|
||||
<circle cx="27" cy="5" r="7" fill={"black"} />
|
||||
</mask>
|
||||
<mask id="user">
|
||||
<rect x="0" y="0" width="32" height="32" fill="white" />
|
||||
<circle cx="27" cy="27" r="7" fill={"black"} />
|
||||
</mask>
|
||||
<mask id="overlap">
|
||||
<rect x="0" y="0" width="32" height="32" fill="white" />
|
||||
<circle cx="32" cy="16" r="18" fill={"black"} />
|
||||
</mask>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,181 +19,181 @@ const zoomIn = keyframes`
|
||||
`;
|
||||
|
||||
const ModalBase = styled.div`
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 9999;
|
||||
position: fixed;
|
||||
max-height: 100%;
|
||||
user-select: none;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 9999;
|
||||
position: fixed;
|
||||
max-height: 100%;
|
||||
user-select: none;
|
||||
|
||||
animation-name: ${open};
|
||||
animation-duration: 0.2s;
|
||||
animation-name: ${open};
|
||||
animation-duration: 0.2s;
|
||||
|
||||
display: grid;
|
||||
overflow-y: auto;
|
||||
place-items: center;
|
||||
display: grid;
|
||||
overflow-y: auto;
|
||||
place-items: center;
|
||||
|
||||
color: var(--foreground);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
color: var(--foreground);
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
`;
|
||||
|
||||
const ModalContainer = styled.div`
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
max-width: calc(100vw - 20px);
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
max-width: calc(100vw - 20px);
|
||||
|
||||
animation-name: ${zoomIn};
|
||||
animation-duration: 0.25s;
|
||||
animation-timing-function: cubic-bezier(0.3, 0.3, 0.18, 1.1);
|
||||
animation-name: ${zoomIn};
|
||||
animation-duration: 0.25s;
|
||||
animation-timing-function: cubic-bezier(0.3, 0.3, 0.18, 1.1);
|
||||
`;
|
||||
|
||||
const ModalContent = styled.div<
|
||||
{ [key in "attachment" | "noBackground" | "border" | "padding"]?: boolean }
|
||||
{ [key in "attachment" | "noBackground" | "border" | "padding"]?: boolean }
|
||||
>`
|
||||
border-radius: 8px;
|
||||
text-overflow: ellipsis;
|
||||
border-radius: 8px;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
!props.noBackground &&
|
||||
css`
|
||||
background: var(--secondary-header);
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.padding &&
|
||||
css`
|
||||
padding: 1.5em;
|
||||
`}
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.attachment &&
|
||||
css`
|
||||
border-radius: 8px 8px 0 0;
|
||||
`}
|
||||
!props.noBackground &&
|
||||
css`
|
||||
background: var(--secondary-header);
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.border &&
|
||||
css`
|
||||
border-radius: 10px;
|
||||
border: 2px solid var(--secondary-background);
|
||||
`}
|
||||
props.padding &&
|
||||
css`
|
||||
padding: 1.5em;
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.attachment &&
|
||||
css`
|
||||
border-radius: 8px 8px 0 0;
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.border &&
|
||||
css`
|
||||
border-radius: 10px;
|
||||
border: 2px solid var(--secondary-background);
|
||||
`}
|
||||
`;
|
||||
|
||||
const ModalActions = styled.div`
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
|
||||
padding: 1em 1.5em;
|
||||
border-radius: 0 0 8px 8px;
|
||||
background: var(--secondary-background);
|
||||
padding: 1em 1.5em;
|
||||
border-radius: 0 0 8px 8px;
|
||||
background: var(--secondary-background);
|
||||
`;
|
||||
|
||||
export interface Action {
|
||||
text: Children;
|
||||
onClick: () => void;
|
||||
confirmation?: boolean;
|
||||
contrast?: boolean;
|
||||
error?: boolean;
|
||||
text: Children;
|
||||
onClick: () => void;
|
||||
confirmation?: boolean;
|
||||
contrast?: boolean;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
children?: Children;
|
||||
title?: Children;
|
||||
children?: Children;
|
||||
title?: Children;
|
||||
|
||||
disallowClosing?: boolean;
|
||||
noBackground?: boolean;
|
||||
dontModal?: boolean;
|
||||
padding?: boolean;
|
||||
disallowClosing?: boolean;
|
||||
noBackground?: boolean;
|
||||
dontModal?: boolean;
|
||||
padding?: boolean;
|
||||
|
||||
onClose: () => void;
|
||||
actions?: Action[];
|
||||
disabled?: boolean;
|
||||
border?: boolean;
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
actions?: Action[];
|
||||
disabled?: boolean;
|
||||
border?: boolean;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
export default function Modal(props: Props) {
|
||||
if (!props.visible) return null;
|
||||
if (!props.visible) return null;
|
||||
|
||||
let content = (
|
||||
<ModalContent
|
||||
attachment={!!props.actions}
|
||||
noBackground={props.noBackground}
|
||||
border={props.border}
|
||||
padding={props.padding ?? !props.dontModal}>
|
||||
{props.title && <h3>{props.title}</h3>}
|
||||
{props.children}
|
||||
</ModalContent>
|
||||
);
|
||||
let content = (
|
||||
<ModalContent
|
||||
attachment={!!props.actions}
|
||||
noBackground={props.noBackground}
|
||||
border={props.border}
|
||||
padding={props.padding ?? !props.dontModal}>
|
||||
{props.title && <h3>{props.title}</h3>}
|
||||
{props.children}
|
||||
</ModalContent>
|
||||
);
|
||||
|
||||
if (props.dontModal) {
|
||||
return content;
|
||||
}
|
||||
if (props.dontModal) {
|
||||
return content;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (props.disallowClosing) return;
|
||||
useEffect(() => {
|
||||
if (props.disallowClosing) return;
|
||||
|
||||
function keyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
props.onClose();
|
||||
}
|
||||
}
|
||||
function keyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
props.onClose();
|
||||
}
|
||||
}
|
||||
|
||||
document.body.addEventListener("keydown", keyDown);
|
||||
return () => document.body.removeEventListener("keydown", keyDown);
|
||||
}, [props.disallowClosing, props.onClose]);
|
||||
document.body.addEventListener("keydown", keyDown);
|
||||
return () => document.body.removeEventListener("keydown", keyDown);
|
||||
}, [props.disallowClosing, props.onClose]);
|
||||
|
||||
let confirmationAction = props.actions?.find(
|
||||
(action) => action.confirmation,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!confirmationAction) return;
|
||||
let confirmationAction = props.actions?.find(
|
||||
(action) => action.confirmation,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!confirmationAction) return;
|
||||
|
||||
// ! FIXME: this may be done better if we
|
||||
// ! can focus the button although that
|
||||
// ! doesn't seem to work...
|
||||
function keyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter") {
|
||||
confirmationAction!.onClick();
|
||||
}
|
||||
}
|
||||
// ! FIXME: this may be done better if we
|
||||
// ! can focus the button although that
|
||||
// ! doesn't seem to work...
|
||||
function keyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Enter") {
|
||||
confirmationAction!.onClick();
|
||||
}
|
||||
}
|
||||
|
||||
document.body.addEventListener("keydown", keyDown);
|
||||
return () => document.body.removeEventListener("keydown", keyDown);
|
||||
}, [confirmationAction]);
|
||||
document.body.addEventListener("keydown", keyDown);
|
||||
return () => document.body.removeEventListener("keydown", keyDown);
|
||||
}, [confirmationAction]);
|
||||
|
||||
return createPortal(
|
||||
<ModalBase
|
||||
onClick={(!props.disallowClosing && props.onClose) || undefined}>
|
||||
<ModalContainer onClick={(e) => (e.cancelBubble = true)}>
|
||||
{content}
|
||||
{props.actions && (
|
||||
<ModalActions>
|
||||
{props.actions.map((x) => (
|
||||
<Button
|
||||
contrast={x.contrast ?? true}
|
||||
error={x.error ?? false}
|
||||
onClick={x.onClick}
|
||||
disabled={props.disabled}>
|
||||
{x.text}
|
||||
</Button>
|
||||
))}
|
||||
</ModalActions>
|
||||
)}
|
||||
</ModalContainer>
|
||||
</ModalBase>,
|
||||
document.body,
|
||||
);
|
||||
return createPortal(
|
||||
<ModalBase
|
||||
onClick={(!props.disallowClosing && props.onClose) || undefined}>
|
||||
<ModalContainer onClick={(e) => (e.cancelBubble = true)}>
|
||||
{content}
|
||||
{props.actions && (
|
||||
<ModalActions>
|
||||
{props.actions.map((x) => (
|
||||
<Button
|
||||
contrast={x.contrast ?? true}
|
||||
error={x.error ?? false}
|
||||
onClick={x.onClick}
|
||||
disabled={props.disabled}>
|
||||
{x.text}
|
||||
</Button>
|
||||
))}
|
||||
</ModalActions>
|
||||
)}
|
||||
</ModalContainer>
|
||||
</ModalBase>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,60 +5,60 @@ import { Text } from "preact-i18n";
|
||||
import { Children } from "../../types/Preact";
|
||||
|
||||
type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, "children" | "as"> & {
|
||||
error?: string;
|
||||
block?: boolean;
|
||||
spaced?: boolean;
|
||||
children?: Children;
|
||||
type?: "default" | "subtle" | "error";
|
||||
error?: string;
|
||||
block?: boolean;
|
||||
spaced?: boolean;
|
||||
children?: Children;
|
||||
type?: "default" | "subtle" | "error";
|
||||
};
|
||||
|
||||
const OverlineBase = styled.div<Omit<Props, "children" | "error">>`
|
||||
display: inline;
|
||||
margin: 0.4em 0;
|
||||
|
||||
${(props) =>
|
||||
props.spaced &&
|
||||
css`
|
||||
margin-top: 0.8em;
|
||||
`}
|
||||
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
text-transform: uppercase;
|
||||
|
||||
${(props) =>
|
||||
props.type === "subtle" &&
|
||||
css`
|
||||
font-size: 12px;
|
||||
color: var(--secondary-foreground);
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.type === "error" &&
|
||||
css`
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--error);
|
||||
`}
|
||||
display: inline;
|
||||
margin: 0.4em 0;
|
||||
|
||||
${(props) =>
|
||||
props.block &&
|
||||
css`
|
||||
display: block;
|
||||
`}
|
||||
props.spaced &&
|
||||
css`
|
||||
margin-top: 0.8em;
|
||||
`}
|
||||
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--foreground);
|
||||
text-transform: uppercase;
|
||||
|
||||
${(props) =>
|
||||
props.type === "subtle" &&
|
||||
css`
|
||||
font-size: 12px;
|
||||
color: var(--secondary-foreground);
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.type === "error" &&
|
||||
css`
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--error);
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.block &&
|
||||
css`
|
||||
display: block;
|
||||
`}
|
||||
`;
|
||||
|
||||
export default function Overline(props: Props) {
|
||||
return (
|
||||
<OverlineBase {...props}>
|
||||
{props.children}
|
||||
{props.children && props.error && <> · </>}
|
||||
{props.error && (
|
||||
<Overline type="error">
|
||||
<Text id={`error.${props.error}`}>{props.error}</Text>
|
||||
</Overline>
|
||||
)}
|
||||
</OverlineBase>
|
||||
);
|
||||
return (
|
||||
<OverlineBase {...props}>
|
||||
{props.children}
|
||||
{props.children && props.error && <> · </>}
|
||||
{props.error && (
|
||||
<Overline type="error">
|
||||
<Text id={`error.${props.error}`}>{props.error}</Text>
|
||||
</Overline>
|
||||
)}
|
||||
</OverlineBase>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,83 +21,83 @@ const prRing = keyframes`
|
||||
`;
|
||||
|
||||
const PreloaderBase = styled.div`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
display: grid;
|
||||
place-items: center;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
|
||||
.spinner {
|
||||
width: 58px;
|
||||
display: flex;
|
||||
text-align: center;
|
||||
margin: 100px auto 0;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.spinner {
|
||||
width: 58px;
|
||||
display: flex;
|
||||
text-align: center;
|
||||
margin: 100px auto 0;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.spinner > div {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background-color: var(--tertiary-foreground);
|
||||
.spinner > div {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background-color: var(--tertiary-foreground);
|
||||
|
||||
border-radius: 100%;
|
||||
display: inline-block;
|
||||
animation: ${skSpinner} 1.4s infinite ease-in-out both;
|
||||
}
|
||||
border-radius: 100%;
|
||||
display: inline-block;
|
||||
animation: ${skSpinner} 1.4s infinite ease-in-out both;
|
||||
}
|
||||
|
||||
.spinner div:nth-child(1) {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
.spinner div:nth-child(1) {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
.spinner div:nth-child(2) {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
.spinner div:nth-child(2) {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
.ring {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 52px;
|
||||
}
|
||||
.ring {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 52px;
|
||||
}
|
||||
|
||||
.ring div {
|
||||
width: 32px;
|
||||
margin: 8px;
|
||||
height: 32px;
|
||||
display: block;
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid #fff;
|
||||
animation: ${prRing} 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
|
||||
border-color: #fff transparent transparent transparent;
|
||||
}
|
||||
.ring div {
|
||||
width: 32px;
|
||||
margin: 8px;
|
||||
height: 32px;
|
||||
display: block;
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
box-sizing: border-box;
|
||||
border: 2px solid #fff;
|
||||
animation: ${prRing} 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
|
||||
border-color: #fff transparent transparent transparent;
|
||||
}
|
||||
|
||||
.ring div:nth-child(1) {
|
||||
animation-delay: -0.45s;
|
||||
}
|
||||
.ring div:nth-child(1) {
|
||||
animation-delay: -0.45s;
|
||||
}
|
||||
|
||||
.ring div:nth-child(2) {
|
||||
animation-delay: -0.3s;
|
||||
}
|
||||
.ring div:nth-child(2) {
|
||||
animation-delay: -0.3s;
|
||||
}
|
||||
|
||||
.ring div:nth-child(3) {
|
||||
animation-delay: -0.15s;
|
||||
}
|
||||
.ring div:nth-child(3) {
|
||||
animation-delay: -0.15s;
|
||||
}
|
||||
`;
|
||||
|
||||
interface Props {
|
||||
type: "spinner" | "ring";
|
||||
type: "spinner" | "ring";
|
||||
}
|
||||
|
||||
export default function Preloader({ type }: Props) {
|
||||
return (
|
||||
<PreloaderBase>
|
||||
<div class={type}>
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
</div>
|
||||
</PreloaderBase>
|
||||
);
|
||||
return (
|
||||
<PreloaderBase>
|
||||
<div class={type}>
|
||||
<div />
|
||||
<div />
|
||||
<div />
|
||||
</div>
|
||||
</PreloaderBase>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,108 +4,108 @@ import styled, { css } from "styled-components";
|
||||
import { Children } from "../../types/Preact";
|
||||
|
||||
interface Props {
|
||||
children: Children;
|
||||
description?: Children;
|
||||
children: Children;
|
||||
description?: Children;
|
||||
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
onSelect: () => void;
|
||||
checked: boolean;
|
||||
disabled?: boolean;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
interface BaseProps {
|
||||
selected: boolean;
|
||||
selected: boolean;
|
||||
}
|
||||
|
||||
const RadioBase = styled.label<BaseProps>`
|
||||
gap: 4px;
|
||||
z-index: 1;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
z-index: 1;
|
||||
padding: 4px;
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
align-items: center;
|
||||
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
border-radius: 4px;
|
||||
transition: 0.2s ease all;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
user-select: none;
|
||||
border-radius: 4px;
|
||||
transition: 0.2s ease all;
|
||||
|
||||
&:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
&:hover {
|
||||
background: var(--hover);
|
||||
}
|
||||
|
||||
> input {
|
||||
display: none;
|
||||
}
|
||||
> input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
> div {
|
||||
margin: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
border-radius: 50%;
|
||||
place-items: center;
|
||||
background: var(--foreground);
|
||||
> div {
|
||||
margin: 4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
border-radius: 50%;
|
||||
place-items: center;
|
||||
background: var(--foreground);
|
||||
|
||||
svg {
|
||||
color: var(--foreground);
|
||||
/*stroke-width: 2;*/
|
||||
}
|
||||
}
|
||||
svg {
|
||||
color: var(--foreground);
|
||||
/*stroke-width: 2;*/
|
||||
}
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.selected &&
|
||||
css`
|
||||
color: white;
|
||||
cursor: default;
|
||||
background: var(--accent);
|
||||
${(props) =>
|
||||
props.selected &&
|
||||
css`
|
||||
color: white;
|
||||
cursor: default;
|
||||
background: var(--accent);
|
||||
|
||||
> div {
|
||||
background: white;
|
||||
}
|
||||
> div {
|
||||
background: white;
|
||||
}
|
||||
|
||||
> div svg {
|
||||
color: var(--accent);
|
||||
}
|
||||
> div svg {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
`}
|
||||
&:hover {
|
||||
background: var(--accent);
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const RadioDescription = styled.span<BaseProps>`
|
||||
font-size: 0.8em;
|
||||
font-weight: 400;
|
||||
color: var(--secondary-foreground);
|
||||
font-size: 0.8em;
|
||||
font-weight: 400;
|
||||
color: var(--secondary-foreground);
|
||||
|
||||
${(props) =>
|
||||
props.selected &&
|
||||
css`
|
||||
color: white;
|
||||
`}
|
||||
${(props) =>
|
||||
props.selected &&
|
||||
css`
|
||||
color: white;
|
||||
`}
|
||||
`;
|
||||
|
||||
export default function Radio(props: Props) {
|
||||
return (
|
||||
<RadioBase
|
||||
selected={props.checked}
|
||||
disabled={props.disabled}
|
||||
onClick={() =>
|
||||
!props.disabled && props.onSelect && props.onSelect()
|
||||
}>
|
||||
<div>
|
||||
<Circle size={12} />
|
||||
</div>
|
||||
<input type="radio" checked={props.checked} />
|
||||
<span>
|
||||
<span>{props.children}</span>
|
||||
{props.description && (
|
||||
<RadioDescription selected={props.checked}>
|
||||
{props.description}
|
||||
</RadioDescription>
|
||||
)}
|
||||
</span>
|
||||
</RadioBase>
|
||||
);
|
||||
return (
|
||||
<RadioBase
|
||||
selected={props.checked}
|
||||
disabled={props.disabled}
|
||||
onClick={() =>
|
||||
!props.disabled && props.onSelect && props.onSelect()
|
||||
}>
|
||||
<div>
|
||||
<Circle size={12} />
|
||||
</div>
|
||||
<input type="radio" checked={props.checked} />
|
||||
<span>
|
||||
<span>{props.children}</span>
|
||||
{props.description && (
|
||||
<RadioDescription selected={props.checked}>
|
||||
{props.description}
|
||||
</RadioDescription>
|
||||
)}
|
||||
</span>
|
||||
</RadioBase>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import styled, { css } from "styled-components";
|
||||
|
||||
export interface TextAreaProps {
|
||||
code?: boolean;
|
||||
padding?: number;
|
||||
lineHeight?: number;
|
||||
hideBorder?: boolean;
|
||||
code?: boolean;
|
||||
padding?: number;
|
||||
lineHeight?: number;
|
||||
hideBorder?: boolean;
|
||||
}
|
||||
|
||||
export const TEXT_AREA_BORDER_WIDTH = 2;
|
||||
@@ -12,46 +12,46 @@ export const DEFAULT_TEXT_AREA_PADDING = 16;
|
||||
export const DEFAULT_LINE_HEIGHT = 20;
|
||||
|
||||
export default styled.textarea<TextAreaProps>`
|
||||
width: 100%;
|
||||
resize: none;
|
||||
display: block;
|
||||
color: var(--foreground);
|
||||
background: var(--secondary-background);
|
||||
padding: ${(props) => props.padding ?? DEFAULT_TEXT_AREA_PADDING}px;
|
||||
line-height: ${(props) => props.lineHeight ?? DEFAULT_LINE_HEIGHT}px;
|
||||
width: 100%;
|
||||
resize: none;
|
||||
display: block;
|
||||
color: var(--foreground);
|
||||
background: var(--secondary-background);
|
||||
padding: ${(props) => props.padding ?? DEFAULT_TEXT_AREA_PADDING}px;
|
||||
line-height: ${(props) => props.lineHeight ?? DEFAULT_LINE_HEIGHT}px;
|
||||
|
||||
${(props) =>
|
||||
props.hideBorder &&
|
||||
css`
|
||||
border: none;
|
||||
`}
|
||||
${(props) =>
|
||||
props.hideBorder &&
|
||||
css`
|
||||
border: none;
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
!props.hideBorder &&
|
||||
css`
|
||||
border-radius: 4px;
|
||||
transition: border-color 0.2s ease-in-out;
|
||||
border: ${TEXT_AREA_BORDER_WIDTH}px solid transparent;
|
||||
`}
|
||||
${(props) =>
|
||||
!props.hideBorder &&
|
||||
css`
|
||||
border-radius: 4px;
|
||||
transition: border-color 0.2s ease-in-out;
|
||||
border: ${TEXT_AREA_BORDER_WIDTH}px solid transparent;
|
||||
`}
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
outline: none;
|
||||
|
||||
${(props) =>
|
||||
!props.hideBorder &&
|
||||
css`
|
||||
border: ${TEXT_AREA_BORDER_WIDTH}px solid var(--accent);
|
||||
`}
|
||||
}
|
||||
${(props) =>
|
||||
!props.hideBorder &&
|
||||
css`
|
||||
border: ${TEXT_AREA_BORDER_WIDTH}px solid var(--accent);
|
||||
`}
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.code
|
||||
? css`
|
||||
font-family: var(--monoscape-font-font), monospace;
|
||||
`
|
||||
: css`
|
||||
font-family: inherit;
|
||||
`}
|
||||
${(props) =>
|
||||
props.code
|
||||
? css`
|
||||
font-family: var(--monoscape-font-font), monospace;
|
||||
`
|
||||
: css`
|
||||
font-family: inherit;
|
||||
`}
|
||||
|
||||
font-variant-ligatures: var(--ligatures);
|
||||
font-variant-ligatures: var(--ligatures);
|
||||
`;
|
||||
|
||||
@@ -4,66 +4,66 @@ import styled, { css } from "styled-components";
|
||||
import { Children } from "../../types/Preact";
|
||||
|
||||
interface Props {
|
||||
warning?: boolean;
|
||||
error?: boolean;
|
||||
warning?: boolean;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export const Separator = styled.div<Props>`
|
||||
height: 1px;
|
||||
width: calc(100% - 10px);
|
||||
background: var(--secondary-header);
|
||||
margin: 18px auto;
|
||||
height: 1px;
|
||||
width: calc(100% - 10px);
|
||||
background: var(--secondary-header);
|
||||
margin: 18px auto;
|
||||
`;
|
||||
|
||||
export const TipBase = styled.div<Props>`
|
||||
display: flex;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
align-items: center;
|
||||
|
||||
font-size: 14px;
|
||||
border-radius: 7px;
|
||||
background: var(--primary-header);
|
||||
border: 2px solid var(--secondary-header);
|
||||
font-size: 14px;
|
||||
border-radius: 7px;
|
||||
background: var(--primary-header);
|
||||
border: 2px solid var(--secondary-header);
|
||||
|
||||
a {
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
a {
|
||||
cursor: pointer;
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
svg {
|
||||
flex-shrink: 0;
|
||||
margin-inline-end: 10px;
|
||||
}
|
||||
|
||||
${(props) =>
|
||||
props.warning &&
|
||||
css`
|
||||
color: var(--warning);
|
||||
border: 2px solid var(--warning);
|
||||
background: var(--secondary-header);
|
||||
`}
|
||||
${(props) =>
|
||||
props.warning &&
|
||||
css`
|
||||
color: var(--warning);
|
||||
border: 2px solid var(--warning);
|
||||
background: var(--secondary-header);
|
||||
`}
|
||||
|
||||
${(props) =>
|
||||
props.error &&
|
||||
css`
|
||||
color: var(--error);
|
||||
border: 2px solid var(--error);
|
||||
background: var(--secondary-header);
|
||||
`}
|
||||
${(props) =>
|
||||
props.error &&
|
||||
css`
|
||||
color: var(--error);
|
||||
border: 2px solid var(--error);
|
||||
background: var(--secondary-header);
|
||||
`}
|
||||
`;
|
||||
|
||||
export default function Tip(props: Props & { children: Children }) {
|
||||
const { children, ...tipProps } = props;
|
||||
return (
|
||||
<>
|
||||
<Separator />
|
||||
<TipBase {...tipProps}>
|
||||
<InfoCircle size={20} />
|
||||
<span>{props.children}</span>
|
||||
</TipBase>
|
||||
</>
|
||||
);
|
||||
const { children, ...tipProps } = props;
|
||||
return (
|
||||
<>
|
||||
<Separator />
|
||||
<TipBase {...tipProps}>
|
||||
<InfoCircle size={20} />
|
||||
<span>{props.children}</span>
|
||||
</TipBase>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user