forked from abner/for-legacy-web
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" />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user