Use tabWidth 4 without actual tabs.

This commit is contained in:
Paul 2021-07-05 11:25:20 +01:00
parent 7bd33d8d34
commit b5a11d5c8f
180 changed files with 16619 additions and 16622 deletions

View file

@ -1,6 +1,5 @@
module.exports = {
"tabWidth": 4,
"useTabs": true,
"trailingComma": "all",
"jsxBracketSameLine": true,
"importOrder": ["preact|classnames|.scss$", "/(lib)", "/(redux)", "/(context)", "/(ui|common)|.svg$", "^[./]"],

File diff suppressed because it is too large Load diff

View file

@ -4,26 +4,26 @@ import message from "./message.mp3";
import outbound from "./outbound.mp3";
const SoundMap: { [key in Sounds]: string } = {
message,
outbound,
call_join,
call_leave,
message,
outbound,
call_join,
call_leave,
};
export type Sounds = "message" | "outbound" | "call_join" | "call_leave";
export const SOUNDS_ARRAY: Sounds[] = [
"message",
"outbound",
"call_join",
"call_leave",
"message",
"outbound",
"call_join",
"call_leave",
];
export function playSound(sound: Sounds) {
let file = SoundMap[sound];
let el = new Audio(file);
try {
el.play();
} catch (err) {
console.error("Failed to play audio file", file, err);
}
let file = SoundMap[sound];
let el = new Audio(file);
try {
el.play();
} catch (err) {
console.error("Failed to play audio file", file, err);
}
}

View file

@ -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>
);
}

View file

@ -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}
/>
);
}

View file

@ -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>
);
}

View file

@ -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,
)}" />`;
}

View file

@ -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%;
`}
`;

View file

@ -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,
};
});

View file

@ -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>
);
}

View file

@ -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}
/>
);
}

View file

@ -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}
/>
);
}

View file

@ -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>
);
}

View file

@ -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);

View file

@ -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>
);
}

View file

@ -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,
);

View file

@ -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>
);
}

View file

@ -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>
);
}
}
}

View file

@ -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>
);
}
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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],
};
});

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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} />
</>
);
}

View file

@ -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" />;
}

View file

@ -3,15 +3,15 @@ import { Suspense, lazy } from "preact/compat";
const Renderer = lazy(() => import("./Renderer"));
export interface MarkdownProps {
content?: string;
disallowBigEmoji?: boolean;
content?: string;
disallowBigEmoji?: boolean;
}
export default function Markdown(props: MarkdownProps) {
return (
// @ts-expect-error
<Suspense fallback={props.content}>
<Renderer {...props} />
</Suspense>
);
return (
// @ts-expect-error
<Suspense fallback={props.content}>
<Renderer {...props} />
</Suspense>
);
}

View file

@ -26,167 +26,167 @@ import { MarkdownProps } from "./Markdown";
// TODO: global.d.ts file for defining globals
declare global {
interface Window {
copycode: (element: HTMLDivElement) => void;
}
interface Window {
copycode: (element: HTMLDivElement) => void;
}
}
// Handler for code block copy.
if (typeof window !== "undefined") {
window.copycode = function (element: HTMLDivElement) {
try {
let code = element.parentElement?.parentElement?.children[1];
if (code) {
navigator.clipboard.writeText(code.textContent?.trim() ?? "");
}
} catch (e) {}
};
window.copycode = function (element: HTMLDivElement) {
try {
let code = element.parentElement?.parentElement?.children[1];
if (code) {
navigator.clipboard.writeText(code.textContent?.trim() ?? "");
}
} catch (e) {}
};
}
export const md: MarkdownIt = MarkdownIt({
breaks: true,
linkify: true,
highlight: (str, lang) => {
let v = Prism.languages[lang];
if (v) {
let out = Prism.highlight(str, v, lang);
return `<pre class="code"><div class="lang"><div onclick="copycode(this)">${lang}</div></div><code class="language-${lang}">${out}</code></pre>`;
}
breaks: true,
linkify: true,
highlight: (str, lang) => {
let v = Prism.languages[lang];
if (v) {
let out = Prism.highlight(str, v, lang);
return `<pre class="code"><div class="lang"><div onclick="copycode(this)">${lang}</div></div><code class="language-${lang}">${out}</code></pre>`;
}
return `<pre class="code"><code>${md.utils.escapeHtml(
str,
)}</code></pre>`;
},
return `<pre class="code"><code>${md.utils.escapeHtml(
str,
)}</code></pre>`;
},
})
.disable("image")
.use(MarkdownEmoji, { defs: emojiDictionary })
.use(MarkdownSpoilers)
.use(MarkdownSup)
.use(MarkdownSub)
.use(MarkdownKatex, {
throwOnError: false,
maxExpand: 0,
});
.disable("image")
.use(MarkdownEmoji, { defs: emojiDictionary })
.use(MarkdownSpoilers)
.use(MarkdownSup)
.use(MarkdownSub)
.use(MarkdownKatex, {
throwOnError: false,
maxExpand: 0,
});
// ? Force links to open _blank.
// From: https://github.com/markdown-it/markdown-it/blob/master/docs/architecture.md#renderer
const defaultRender =
md.renderer.rules.link_open ||
function (tokens, idx, options, _env, self) {
return self.renderToken(tokens, idx, options);
};
md.renderer.rules.link_open ||
function (tokens, idx, options, _env, self) {
return self.renderToken(tokens, idx, options);
};
// TODO: global.d.ts file for defining globals
declare global {
interface Window {
internalHandleURL: (element: HTMLAnchorElement) => void;
}
interface Window {
internalHandleURL: (element: HTMLAnchorElement) => void;
}
}
// Handler for internal links, pushes events to React using magic.
if (typeof window !== "undefined") {
window.internalHandleURL = function (element: HTMLAnchorElement) {
const url = new URL(element.href, location.href);
const pathname = url.pathname;
window.internalHandleURL = function (element: HTMLAnchorElement) {
const url = new URL(element.href, location.href);
const pathname = url.pathname;
if (pathname.startsWith("/@")) {
internalEmit("Intermediate", "openProfile", pathname.substr(2));
} else {
internalEmit("Intermediate", "navigate", pathname);
}
};
if (pathname.startsWith("/@")) {
internalEmit("Intermediate", "openProfile", pathname.substr(2));
} else {
internalEmit("Intermediate", "navigate", pathname);
}
};
}
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
let internal;
const hIndex = tokens[idx].attrIndex("href");
if (hIndex >= 0) {
try {
// For internal links, we should use our own handler to use react-router history.
// @ts-ignore
const href = tokens[idx].attrs[hIndex][1];
const url = new URL(href, location.href);
let internal;
const hIndex = tokens[idx].attrIndex("href");
if (hIndex >= 0) {
try {
// For internal links, we should use our own handler to use react-router history.
// @ts-ignore
const href = tokens[idx].attrs[hIndex][1];
const url = new URL(href, location.href);
if (url.hostname === location.hostname) {
internal = true;
// I'm sorry.
tokens[idx].attrPush([
"onclick",
"internalHandleURL(this); return false",
]);
if (url.hostname === location.hostname) {
internal = true;
// I'm sorry.
tokens[idx].attrPush([
"onclick",
"internalHandleURL(this); return false",
]);
if (url.pathname.startsWith("/@")) {
tokens[idx].attrPush(["data-type", "mention"]);
}
}
} catch (err) {
// Ignore the error, treat as normal link.
}
}
if (url.pathname.startsWith("/@")) {
tokens[idx].attrPush(["data-type", "mention"]);
}
}
} catch (err) {
// Ignore the error, treat as normal link.
}
}
if (!internal) {
// Add target=_blank for external links.
const aIndex = tokens[idx].attrIndex("target");
if (!internal) {
// Add target=_blank for external links.
const aIndex = tokens[idx].attrIndex("target");
if (aIndex < 0) {
tokens[idx].attrPush(["target", "_blank"]);
} else {
try {
// @ts-ignore
tokens[idx].attrs[aIndex][1] = "_blank";
} catch (_) {}
}
}
if (aIndex < 0) {
tokens[idx].attrPush(["target", "_blank"]);
} else {
try {
// @ts-ignore
tokens[idx].attrs[aIndex][1] = "_blank";
} catch (_) {}
}
}
return defaultRender(tokens, idx, options, env, self);
return defaultRender(tokens, idx, options, env, self);
};
md.renderer.rules.emoji = function (token, idx) {
return generateEmoji(token[idx].content);
return generateEmoji(token[idx].content);
};
const RE_TWEMOJI = /:(\w+):/g;
export default function Renderer({ content, disallowBigEmoji }: MarkdownProps) {
const client = useContext(AppContext);
if (typeof content === "undefined") return null;
if (content.length === 0) return null;
const client = useContext(AppContext);
if (typeof content === "undefined") return null;
if (content.length === 0) return null;
// We replace the message with the mention at the time of render.
// We don't care if the mention changes.
let newContent = content.replace(
RE_MENTIONS,
(sub: string, ...args: any[]) => {
const id = args[0],
user = client.users.get(id);
// We replace the message with the mention at the time of render.
// We don't care if the mention changes.
let newContent = content.replace(
RE_MENTIONS,
(sub: string, ...args: any[]) => {
const id = args[0],
user = client.users.get(id);
if (user) {
return `[@${user.username}](/@${id})`;
}
if (user) {
return `[@${user.username}](/@${id})`;
}
return sub;
},
);
return sub;
},
);
const useLargeEmojis = disallowBigEmoji
? false
: content.replace(RE_TWEMOJI, "").trim().length === 0;
const useLargeEmojis = disallowBigEmoji
? false
: content.replace(RE_TWEMOJI, "").trim().length === 0;
return (
<span
className={styles.markdown}
dangerouslySetInnerHTML={{
__html: md.render(newContent),
}}
data-large-emojis={useLargeEmojis}
onClick={(ev) => {
if (ev.target) {
let element = ev.currentTarget;
if (element.classList.contains("spoiler")) {
element.classList.add("shown");
}
}
}}
/>
);
return (
<span
className={styles.markdown}
dangerouslySetInnerHTML={{
__html: md.render(newContent),
}}
data-large-emojis={useLargeEmojis}
onClick={(ev) => {
if (ev.target) {
let element = ev.currentTarget;
if (element.classList.contains("spoiler")) {
element.classList.add("shown");
}
}
}}
/>
);
}

View file

@ -13,84 +13,84 @@ import UserIcon from "../common/user/UserIcon";
import IconButton from "../ui/IconButton";
const NavigationBase = styled.div`
z-index: 100;
height: 50px;
display: flex;
background: var(--secondary-background);
z-index: 100;
height: 50px;
display: flex;
background: var(--secondary-background);
`;
const Button = styled.a<{ active: boolean }>`
flex: 1;
flex: 1;
> a,
> div,
> a > div {
width: 100%;
height: 100%;
}
> a,
> div,
> a > div {
width: 100%;
height: 100%;
}
${(props) =>
props.active &&
css`
background: var(--hover);
`}
${(props) =>
props.active &&
css`
background: var(--hover);
`}
`;
interface Props {
lastOpened: LastOpened;
lastOpened: LastOpened;
}
export function BottomNavigation({ lastOpened }: Props) {
const user = useSelf();
const history = useHistory();
const path = useLocation().pathname;
const user = useSelf();
const history = useHistory();
const path = useLocation().pathname;
const channel_id = lastOpened["home"];
const channel_id = lastOpened["home"];
const friendsActive = path.startsWith("/friends");
const settingsActive = path.startsWith("/settings");
const homeActive = !(friendsActive || settingsActive);
const friendsActive = path.startsWith("/friends");
const settingsActive = path.startsWith("/settings");
const homeActive = !(friendsActive || settingsActive);
return (
<NavigationBase>
<Button active={homeActive}>
<IconButton
onClick={() => {
if (settingsActive) {
if (history.length > 0) {
history.goBack();
}
}
return (
<NavigationBase>
<Button active={homeActive}>
<IconButton
onClick={() => {
if (settingsActive) {
if (history.length > 0) {
history.goBack();
}
}
if (channel_id) {
history.push(`/channel/${channel_id}`);
} else {
history.push("/");
}
}}>
<Message size={24} />
</IconButton>
</Button>
<Button active={friendsActive}>
<ConditionalLink active={friendsActive} to="/friends">
<IconButton>
<Group size={25} />
</IconButton>
</ConditionalLink>
</Button>
<Button active={settingsActive}>
<ConditionalLink active={settingsActive} to="/settings">
<IconButton>
<UserIcon target={user} size={26} status={true} />
</IconButton>
</ConditionalLink>
</Button>
</NavigationBase>
);
if (channel_id) {
history.push(`/channel/${channel_id}`);
} else {
history.push("/");
}
}}>
<Message size={24} />
</IconButton>
</Button>
<Button active={friendsActive}>
<ConditionalLink active={friendsActive} to="/friends">
<IconButton>
<Group size={25} />
</IconButton>
</ConditionalLink>
</Button>
<Button active={settingsActive}>
<ConditionalLink active={settingsActive} to="/settings">
<IconButton>
<UserIcon target={user} size={26} status={true} />
</IconButton>
</ConditionalLink>
</Button>
</NavigationBase>
);
}
export default connectState(BottomNavigation, (state) => {
return {
lastOpened: state.lastOpened,
};
return {
lastOpened: state.lastOpened,
};
});

View file

@ -6,27 +6,27 @@ import ServerListSidebar from "./left/ServerListSidebar";
import ServerSidebar from "./left/ServerSidebar";
export default function LeftSidebar() {
return (
<SidebarBase>
<Switch>
<Route path="/settings" />
<Route path="/server/:server/channel/:channel">
<ServerListSidebar />
<ServerSidebar />
</Route>
<Route path="/server/:server">
<ServerListSidebar />
<ServerSidebar />
</Route>
<Route path="/channel/:channel">
<ServerListSidebar />
<HomeSidebar />
</Route>
<Route path="/">
<ServerListSidebar />
<HomeSidebar />
</Route>
</Switch>
</SidebarBase>
);
return (
<SidebarBase>
<Switch>
<Route path="/settings" />
<Route path="/server/:server/channel/:channel">
<ServerListSidebar />
<ServerSidebar />
</Route>
<Route path="/server/:server">
<ServerListSidebar />
<ServerSidebar />
</Route>
<Route path="/channel/:channel">
<ServerListSidebar />
<HomeSidebar />
</Route>
<Route path="/">
<ServerListSidebar />
<HomeSidebar />
</Route>
</Switch>
</SidebarBase>
);
}

View file

@ -4,16 +4,16 @@ import SidebarBase from "./SidebarBase";
import MemberSidebar from "./right/MemberSidebar";
export default function RightSidebar() {
return (
<SidebarBase>
<Switch>
<Route path="/server/:server/channel/:channel">
<MemberSidebar />
</Route>
<Route path="/channel/:channel">
<MemberSidebar />
</Route>
</Switch>
</SidebarBase>
);
return (
<SidebarBase>
<Switch>
<Route path="/server/:server/channel/:channel">
<MemberSidebar />
</Route>
<Route path="/channel/:channel">
<MemberSidebar />
</Route>
</Switch>
</SidebarBase>
);
}

View file

@ -3,36 +3,36 @@ import styled, { css } from "styled-components";
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
export default styled.div`
height: 100%;
display: flex;
user-select: none;
flex-direction: row;
align-items: stretch;
height: 100%;
display: flex;
user-select: none;
flex-direction: row;
align-items: stretch;
`;
export const GenericSidebarBase = styled.div<{ padding?: boolean }>`
height: 100%;
width: 240px;
display: flex;
flex-shrink: 0;
flex-direction: column;
background: var(--secondary-background);
border-end-start-radius: 8px;
height: 100%;
width: 240px;
display: flex;
flex-shrink: 0;
flex-direction: column;
background: var(--secondary-background);
border-end-start-radius: 8px;
${(props) =>
props.padding &&
isTouchscreenDevice &&
css`
padding-bottom: 50px;
`}
${(props) =>
props.padding &&
isTouchscreenDevice &&
css`
padding-bottom: 50px;
`}
`;
export const GenericSidebarList = styled.div`
padding: 6px;
flex-grow: 1;
overflow-y: scroll;
padding: 6px;
flex-grow: 1;
overflow-y: scroll;
> img {
width: 100%;
}
> img {
width: 100%;
}
`;

View file

@ -20,204 +20,204 @@ import IconButton from "../../ui/IconButton";
import { Children } from "../../../types/Preact";
type CommonProps = Omit<
JSX.HTMLAttributes<HTMLDivElement>,
"children" | "as"
JSX.HTMLAttributes<HTMLDivElement>,
"children" | "as"
> & {
active?: boolean;
alert?: "unread" | "mention";
alertCount?: number;
active?: boolean;
alert?: "unread" | "mention";
alertCount?: number;
};
type UserProps = CommonProps & {
user: Users.User;
context?: Channels.Channel;
channel?: Channels.DirectMessageChannel;
user: Users.User;
context?: Channels.Channel;
channel?: Channels.DirectMessageChannel;
};
export function UserButton(props: UserProps) {
const { active, alert, alertCount, user, context, channel, ...divProps } =
props;
const { openScreen } = useIntermediate();
const { active, alert, alertCount, user, context, channel, ...divProps } =
props;
const { openScreen } = useIntermediate();
return (
<div
{...divProps}
className={classNames(styles.item, styles.user)}
data-active={active}
data-alert={typeof alert === "string"}
data-online={
typeof channel !== "undefined" ||
(user.online &&
user.status?.presence !== Users.Presence.Invisible)
}
onContextMenu={attachContextMenu("Menu", {
user: user._id,
channel: channel?._id,
unread: alert,
contextualChannel: context?._id,
})}>
<UserIcon
className={styles.avatar}
target={user}
size={32}
status
/>
<div className={styles.name}>
<div>{user.username}</div>
{
<div className={styles.subText}>
{channel?.last_message && alert ? (
channel.last_message.short
) : (
<UserStatus user={user} />
)}
</div>
}
</div>
<div className={styles.button}>
{context?.channel_type === "Group" &&
context.owner === user._id && (
<Localizer>
<Tooltip
content={<Text id="app.main.groups.owner" />}>
<Crown size={20} />
</Tooltip>
</Localizer>
)}
{alert && (
<div className={styles.alert} data-style={alert}>
{alertCount}
</div>
)}
{!isTouchscreenDevice && channel && (
<IconButton
className={styles.icon}
onClick={(e) =>
stopPropagation(e) &&
openScreen({
id: "special_prompt",
type: "close_dm",
target: channel,
})
}>
<X size={24} />
</IconButton>
)}
</div>
</div>
);
return (
<div
{...divProps}
className={classNames(styles.item, styles.user)}
data-active={active}
data-alert={typeof alert === "string"}
data-online={
typeof channel !== "undefined" ||
(user.online &&
user.status?.presence !== Users.Presence.Invisible)
}
onContextMenu={attachContextMenu("Menu", {
user: user._id,
channel: channel?._id,
unread: alert,
contextualChannel: context?._id,
})}>
<UserIcon
className={styles.avatar}
target={user}
size={32}
status
/>
<div className={styles.name}>
<div>{user.username}</div>
{
<div className={styles.subText}>
{channel?.last_message && alert ? (
channel.last_message.short
) : (
<UserStatus user={user} />
)}
</div>
}
</div>
<div className={styles.button}>
{context?.channel_type === "Group" &&
context.owner === user._id && (
<Localizer>
<Tooltip
content={<Text id="app.main.groups.owner" />}>
<Crown size={20} />
</Tooltip>
</Localizer>
)}
{alert && (
<div className={styles.alert} data-style={alert}>
{alertCount}
</div>
)}
{!isTouchscreenDevice && channel && (
<IconButton
className={styles.icon}
onClick={(e) =>
stopPropagation(e) &&
openScreen({
id: "special_prompt",
type: "close_dm",
target: channel,
})
}>
<X size={24} />
</IconButton>
)}
</div>
</div>
);
}
type ChannelProps = CommonProps & {
channel: Channels.Channel & { unread?: string };
user?: Users.User;
compact?: boolean;
channel: Channels.Channel & { unread?: string };
user?: Users.User;
compact?: boolean;
};
export function ChannelButton(props: ChannelProps) {
const { active, alert, alertCount, channel, user, compact, ...divProps } =
props;
const { active, alert, alertCount, channel, user, compact, ...divProps } =
props;
if (channel.channel_type === "SavedMessages") throw "Invalid channel type.";
if (channel.channel_type === "DirectMessage") {
if (typeof user === "undefined") throw "No user provided.";
return <UserButton {...{ active, alert, channel, user }} />;
}
if (channel.channel_type === "SavedMessages") throw "Invalid channel type.";
if (channel.channel_type === "DirectMessage") {
if (typeof user === "undefined") throw "No user provided.";
return <UserButton {...{ active, alert, channel, user }} />;
}
const { openScreen } = useIntermediate();
const { openScreen } = useIntermediate();
return (
<div
{...divProps}
data-active={active}
data-alert={typeof alert === "string"}
aria-label={{}} /*FIXME: ADD ARIA LABEL*/
className={classNames(styles.item, { [styles.compact]: compact })}
onContextMenu={attachContextMenu("Menu", {
channel: channel._id,
unread: typeof channel.unread !== "undefined",
})}>
<ChannelIcon
className={styles.avatar}
target={channel}
size={compact ? 24 : 32}
/>
<div className={styles.name}>
<div>{channel.name}</div>
{channel.channel_type === "Group" && (
<div className={styles.subText}>
{channel.last_message && alert ? (
channel.last_message.short
) : (
<Text
id="quantities.members"
plural={channel.recipients.length}
fields={{ count: channel.recipients.length }}
/>
)}
</div>
)}
</div>
<div className={styles.button}>
{alert && (
<div className={styles.alert} data-style={alert}>
{alertCount}
</div>
)}
{!isTouchscreenDevice && channel.channel_type === "Group" && (
<IconButton
className={styles.icon}
onClick={() =>
openScreen({
id: "special_prompt",
type: "leave_group",
target: channel,
})
}>
<X size={24} />
</IconButton>
)}
</div>
</div>
);
return (
<div
{...divProps}
data-active={active}
data-alert={typeof alert === "string"}
aria-label={{}} /*FIXME: ADD ARIA LABEL*/
className={classNames(styles.item, { [styles.compact]: compact })}
onContextMenu={attachContextMenu("Menu", {
channel: channel._id,
unread: typeof channel.unread !== "undefined",
})}>
<ChannelIcon
className={styles.avatar}
target={channel}
size={compact ? 24 : 32}
/>
<div className={styles.name}>
<div>{channel.name}</div>
{channel.channel_type === "Group" && (
<div className={styles.subText}>
{channel.last_message && alert ? (
channel.last_message.short
) : (
<Text
id="quantities.members"
plural={channel.recipients.length}
fields={{ count: channel.recipients.length }}
/>
)}
</div>
)}
</div>
<div className={styles.button}>
{alert && (
<div className={styles.alert} data-style={alert}>
{alertCount}
</div>
)}
{!isTouchscreenDevice && channel.channel_type === "Group" && (
<IconButton
className={styles.icon}
onClick={() =>
openScreen({
id: "special_prompt",
type: "leave_group",
target: channel,
})
}>
<X size={24} />
</IconButton>
)}
</div>
</div>
);
}
type ButtonProps = CommonProps & {
onClick?: () => void;
children?: Children;
className?: string;
compact?: boolean;
onClick?: () => void;
children?: Children;
className?: string;
compact?: boolean;
};
export default function ButtonItem(props: ButtonProps) {
const {
active,
alert,
alertCount,
onClick,
className,
children,
compact,
...divProps
} = props;
const {
active,
alert,
alertCount,
onClick,
className,
children,
compact,
...divProps
} = props;
return (
<div
{...divProps}
className={classNames(
styles.item,
{ [styles.compact]: compact, [styles.normal]: !compact },
className,
)}
onClick={onClick}
data-active={active}
data-alert={typeof alert === "string"}>
<div className={styles.content}>{children}</div>
{alert && (
<div className={styles.alert} data-style={alert}>
{alertCount}
</div>
)}
</div>
);
return (
<div
{...divProps}
className={classNames(
styles.item,
{ [styles.compact]: compact, [styles.normal]: !compact },
className,
)}
onClick={onClick}
data-active={active}
data-alert={typeof alert === "string"}>
<div className={styles.content}>{children}</div>
{alert && (
<div className={styles.alert} data-style={alert}>
{alertCount}
</div>
)}
</div>
);
}

View file

@ -2,39 +2,39 @@ import { Text } from "preact-i18n";
import { useContext } from "preact/hooks";
import {
ClientStatus,
StatusContext,
ClientStatus,
StatusContext,
} from "../../../context/revoltjs/RevoltClient";
import Banner from "../../ui/Banner";
export default function ConnectionStatus() {
const status = useContext(StatusContext);
const status = useContext(StatusContext);
if (status === ClientStatus.OFFLINE) {
return (
<Banner>
<Text id="app.special.status.offline" />
</Banner>
);
} else if (status === ClientStatus.DISCONNECTED) {
return (
<Banner>
<Text id="app.special.status.disconnected" />
</Banner>
);
} else if (status === ClientStatus.CONNECTING) {
return (
<Banner>
<Text id="app.special.status.connecting" />
</Banner>
);
} else if (status === ClientStatus.RECONNECTING) {
return (
<Banner>
<Text id="app.special.status.reconnecting" />
</Banner>
);
}
return null;
if (status === ClientStatus.OFFLINE) {
return (
<Banner>
<Text id="app.special.status.offline" />
</Banner>
);
} else if (status === ClientStatus.DISCONNECTED) {
return (
<Banner>
<Text id="app.special.status.disconnected" />
</Banner>
);
} else if (status === ClientStatus.CONNECTING) {
return (
<Banner>
<Text id="app.special.status.connecting" />
</Banner>
);
} else if (status === ClientStatus.RECONNECTING) {
return (
<Banner>
<Text id="app.special.status.reconnecting" />
</Banner>
);
}
return null;
}

View file

@ -1,8 +1,8 @@
import {
Home,
UserDetail,
Wrench,
Notepad,
Home,
UserDetail,
Wrench,
Notepad,
} from "@styled-icons/boxicons-solid";
import { Link, Redirect, useLocation, useParams } from "react-router-dom";
import { Channels } from "revolt.js/dist/api/objects";
@ -22,9 +22,9 @@ import { Unreads } from "../../../redux/reducers/unreads";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import {
useDMs,
useForceUpdate,
useUsers,
useDMs,
useForceUpdate,
useUsers,
} from "../../../context/revoltjs/hooks";
import UserHeader from "../../common/user/UserHeader";
@ -37,157 +37,157 @@ import ButtonItem, { ChannelButton } from "../items/ButtonItem";
import ConnectionStatus from "../items/ConnectionStatus";
type Props = {
unreads: Unreads;
unreads: Unreads;
};
function HomeSidebar(props: Props) {
const { pathname } = useLocation();
const client = useContext(AppContext);
const { channel } = useParams<{ channel: string }>();
const { openScreen } = useIntermediate();
const { pathname } = useLocation();
const client = useContext(AppContext);
const { channel } = useParams<{ channel: string }>();
const { openScreen } = useIntermediate();
const ctx = useForceUpdate();
const channels = useDMs(ctx);
const ctx = useForceUpdate();
const channels = useDMs(ctx);
const obj = channels.find((x) => x?._id === channel);
if (channel && !obj) return <Redirect to="/" />;
if (obj) useUnreads({ ...props, channel: obj });
const obj = channels.find((x) => x?._id === channel);
if (channel && !obj) return <Redirect to="/" />;
if (obj) useUnreads({ ...props, channel: obj });
useEffect(() => {
if (!channel) return;
useEffect(() => {
if (!channel) return;
dispatch({
type: "LAST_OPENED_SET",
parent: "home",
child: channel,
});
}, [channel]);
dispatch({
type: "LAST_OPENED_SET",
parent: "home",
child: channel,
});
}, [channel]);
const channelsArr = channels
.filter((x) => x.channel_type !== "SavedMessages")
.map((x) => mapChannelWithUnread(x, props.unreads));
const channelsArr = channels
.filter((x) => x.channel_type !== "SavedMessages")
.map((x) => mapChannelWithUnread(x, props.unreads));
const users = useUsers(
(
channelsArr as (
| Channels.DirectMessageChannel
| Channels.GroupChannel
)[]
).reduce((prev: any, cur) => [...prev, ...cur.recipients], []),
ctx,
);
const users = useUsers(
(
channelsArr as (
| Channels.DirectMessageChannel
| Channels.GroupChannel
)[]
).reduce((prev: any, cur) => [...prev, ...cur.recipients], []),
ctx,
);
channelsArr.sort((b, a) => a.timestamp.localeCompare(b.timestamp));
channelsArr.sort((b, a) => a.timestamp.localeCompare(b.timestamp));
return (
<GenericSidebarBase padding>
<UserHeader user={client.user!} />
<ConnectionStatus />
<GenericSidebarList>
{!isTouchscreenDevice && (
<>
<ConditionalLink active={pathname === "/"} to="/">
<ButtonItem active={pathname === "/"}>
<Home size={20} />
<span>
<Text id="app.navigation.tabs.home" />
</span>
</ButtonItem>
</ConditionalLink>
<ConditionalLink
active={pathname === "/friends"}
to="/friends">
<ButtonItem
active={pathname === "/friends"}
alert={
typeof users.find(
(user) =>
user?.relationship ===
UsersNS.Relationship.Incoming,
) !== "undefined"
? "unread"
: undefined
}>
<UserDetail size={20} />
<span>
<Text id="app.navigation.tabs.friends" />
</span>
</ButtonItem>
</ConditionalLink>
</>
)}
<ConditionalLink
active={obj?.channel_type === "SavedMessages"}
to="/open/saved">
<ButtonItem active={obj?.channel_type === "SavedMessages"}>
<Notepad size={20} />
<span>
<Text id="app.navigation.tabs.saved" />
</span>
</ButtonItem>
</ConditionalLink>
{import.meta.env.DEV && (
<Link to="/dev">
<ButtonItem active={pathname === "/dev"}>
<Wrench size={20} />
<span>
<Text id="app.navigation.tabs.dev" />
</span>
</ButtonItem>
</Link>
)}
<Category
text={<Text id="app.main.categories.conversations" />}
action={() =>
openScreen({
id: "special_input",
type: "create_group",
})
}
/>
{channelsArr.length === 0 && <img src={placeholderSVG} />}
{channelsArr.map((x) => {
let user;
if (x.channel_type === "DirectMessage") {
if (!x.active) return null;
return (
<GenericSidebarBase padding>
<UserHeader user={client.user!} />
<ConnectionStatus />
<GenericSidebarList>
{!isTouchscreenDevice && (
<>
<ConditionalLink active={pathname === "/"} to="/">
<ButtonItem active={pathname === "/"}>
<Home size={20} />
<span>
<Text id="app.navigation.tabs.home" />
</span>
</ButtonItem>
</ConditionalLink>
<ConditionalLink
active={pathname === "/friends"}
to="/friends">
<ButtonItem
active={pathname === "/friends"}
alert={
typeof users.find(
(user) =>
user?.relationship ===
UsersNS.Relationship.Incoming,
) !== "undefined"
? "unread"
: undefined
}>
<UserDetail size={20} />
<span>
<Text id="app.navigation.tabs.friends" />
</span>
</ButtonItem>
</ConditionalLink>
</>
)}
<ConditionalLink
active={obj?.channel_type === "SavedMessages"}
to="/open/saved">
<ButtonItem active={obj?.channel_type === "SavedMessages"}>
<Notepad size={20} />
<span>
<Text id="app.navigation.tabs.saved" />
</span>
</ButtonItem>
</ConditionalLink>
{import.meta.env.DEV && (
<Link to="/dev">
<ButtonItem active={pathname === "/dev"}>
<Wrench size={20} />
<span>
<Text id="app.navigation.tabs.dev" />
</span>
</ButtonItem>
</Link>
)}
<Category
text={<Text id="app.main.categories.conversations" />}
action={() =>
openScreen({
id: "special_input",
type: "create_group",
})
}
/>
{channelsArr.length === 0 && <img src={placeholderSVG} />}
{channelsArr.map((x) => {
let user;
if (x.channel_type === "DirectMessage") {
if (!x.active) return null;
let recipient = client.channels.getRecipient(x._id);
user = users.find((x) => x?._id === recipient);
let recipient = client.channels.getRecipient(x._id);
user = users.find((x) => x?._id === recipient);
if (!user) {
console.warn(
`Skipped DM ${x._id} because user was missing.`,
);
return null;
}
}
if (!user) {
console.warn(
`Skipped DM ${x._id} because user was missing.`,
);
return null;
}
}
return (
<ConditionalLink
active={x._id === channel}
to={`/channel/${x._id}`}>
<ChannelButton
user={user}
channel={x}
alert={x.unread}
alertCount={x.alertCount}
active={x._id === channel}
/>
</ConditionalLink>
);
})}
<PaintCounter />
</GenericSidebarList>
</GenericSidebarBase>
);
return (
<ConditionalLink
active={x._id === channel}
to={`/channel/${x._id}`}>
<ChannelButton
user={user}
channel={x}
alert={x.unread}
alertCount={x.alertCount}
active={x._id === channel}
/>
</ConditionalLink>
);
})}
<PaintCounter />
</GenericSidebarList>
</GenericSidebarBase>
);
}
export default connectState(
HomeSidebar,
(state) => {
return {
unreads: state.unreads,
};
},
true,
HomeSidebar,
(state) => {
return {
unreads: state.unreads,
};
},
true,
);

View file

@ -15,10 +15,10 @@ import { Unreads } from "../../../redux/reducers/unreads";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import {
useChannels,
useForceUpdate,
useSelf,
useServers,
useChannels,
useForceUpdate,
useSelf,
useServers,
} from "../../../context/revoltjs/hooks";
import ServerIcon from "../../common/ServerIcon";
@ -31,268 +31,268 @@ import { mapChannelWithUnread } from "./common";
import { Children } from "../../../types/Preact";
function Icon({
children,
unread,
size,
children,
unread,
size,
}: {
children: Children;
unread?: "mention" | "unread";
size: number;
children: Children;
unread?: "mention" | "unread";
size: number;
}) {
return (
<svg width={size} height={size} aria-hidden="true" viewBox="0 0 32 32">
<use href="#serverIndicator" />
<foreignObject
x="0"
y="0"
width="32"
height="32"
mask={unread ? "url(#server)" : undefined}>
{children}
</foreignObject>
{unread === "unread" && (
<circle cx="27" cy="5" r="5" fill={"white"} />
)}
{unread === "mention" && (
<circle cx="27" cy="5" r="5" fill={"red"} />
)}
</svg>
);
return (
<svg width={size} height={size} aria-hidden="true" viewBox="0 0 32 32">
<use href="#serverIndicator" />
<foreignObject
x="0"
y="0"
width="32"
height="32"
mask={unread ? "url(#server)" : undefined}>
{children}
</foreignObject>
{unread === "unread" && (
<circle cx="27" cy="5" r="5" fill={"white"} />
)}
{unread === "mention" && (
<circle cx="27" cy="5" r="5" fill={"red"} />
)}
</svg>
);
}
const ServersBase = styled.div`
width: 56px;
height: 100%;
display: flex;
flex-direction: column;
width: 56px;
height: 100%;
display: flex;
flex-direction: column;
${isTouchscreenDevice &&
css`
padding-bottom: 50px;
`}
${isTouchscreenDevice &&
css`
padding-bottom: 50px;
`}
`;
const ServerList = styled.div`
flex-grow: 1;
display: flex;
overflow-y: scroll;
padding-bottom: 48px;
flex-direction: column;
// border-right: 2px solid var(--accent);
flex-grow: 1;
display: flex;
overflow-y: scroll;
padding-bottom: 48px;
flex-direction: column;
// border-right: 2px solid var(--accent);
scrollbar-width: none;
scrollbar-width: none;
> :first-child > svg {
margin: 6px 0 6px 4px;
}
> :first-child > svg {
margin: 6px 0 6px 4px;
}
&::-webkit-scrollbar {
width: 0px;
}
&::-webkit-scrollbar {
width: 0px;
}
`;
const ServerEntry = styled.div<{ active: boolean; home?: boolean }>`
height: 58px;
display: flex;
align-items: center;
justify-content: flex-end;
height: 58px;
display: flex;
align-items: center;
justify-content: flex-end;
> * {
// outline: 1px solid red;
}
> * {
// outline: 1px solid red;
}
> div {
width: 46px;
height: 46px;
display: grid;
place-items: center;
> div {
width: 46px;
height: 46px;
display: grid;
place-items: center;
border-start-start-radius: 50%;
border-end-start-radius: 50%;
border-start-start-radius: 50%;
border-end-start-radius: 50%;
&:active {
transform: translateY(1px);
}
&:active {
transform: translateY(1px);
}
${(props) =>
props.active &&
css`
background: var(--sidebar-active);
&:active {
transform: none;
}
`}
}
${(props) =>
props.active &&
css`
background: var(--sidebar-active);
&:active {
transform: none;
}
`}
}
span {
width: 6px;
height: 46px;
span {
width: 6px;
height: 46px;
${(props) =>
props.active &&
css`
background-color: var(--sidebar-active);
${(props) =>
props.active &&
css`
background-color: var(--sidebar-active);
&::before,
&::after {
// outline: 1px solid blue;
}
&::before,
&::after {
// outline: 1px solid blue;
}
&::before,
&::after {
content: "";
display: block;
position: relative;
&::before,
&::after {
content: "";
display: block;
position: relative;
width: 31px;
height: 72px;
margin-top: -72px;
margin-left: -25px;
z-index: -1;
width: 31px;
height: 72px;
margin-top: -72px;
margin-left: -25px;
z-index: -1;
background-color: var(--background);
border-bottom-right-radius: 32px;
background-color: var(--background);
border-bottom-right-radius: 32px;
box-shadow: 0 32px 0 0 var(--sidebar-active);
}
box-shadow: 0 32px 0 0 var(--sidebar-active);
}
&::after {
transform: scaleY(-1) translateY(-118px);
}
`}
}
&::after {
transform: scaleY(-1) translateY(-118px);
}
`}
}
${(props) =>
(!props.active || props.home) &&
css`
cursor: pointer;
`}
${(props) =>
(!props.active || props.home) &&
css`
cursor: pointer;
`}
`;
interface Props {
unreads: Unreads;
lastOpened: LastOpened;
unreads: Unreads;
lastOpened: LastOpened;
}
export function ServerListSidebar({ unreads, lastOpened }: Props) {
const ctx = useForceUpdate();
const self = useSelf(ctx);
const activeServers = useServers(undefined, ctx) as Servers.Server[];
const channels = (useChannels(undefined, ctx) as Channel[]).map((x) =>
mapChannelWithUnread(x, unreads),
);
const ctx = useForceUpdate();
const self = useSelf(ctx);
const activeServers = useServers(undefined, ctx) as Servers.Server[];
const channels = (useChannels(undefined, ctx) as Channel[]).map((x) =>
mapChannelWithUnread(x, unreads),
);
const unreadChannels = channels.filter((x) => x.unread).map((x) => x._id);
const unreadChannels = channels.filter((x) => x.unread).map((x) => x._id);
const servers = activeServers.map((server) => {
let alertCount = 0;
for (let id of server.channels) {
let channel = channels.find((x) => x._id === id);
if (channel?.alertCount) {
alertCount += channel.alertCount;
}
}
const servers = activeServers.map((server) => {
let alertCount = 0;
for (let id of server.channels) {
let channel = channels.find((x) => x._id === id);
if (channel?.alertCount) {
alertCount += channel.alertCount;
}
}
return {
...server,
unread: (typeof server.channels.find((x) =>
unreadChannels.includes(x),
) !== "undefined"
? alertCount > 0
? "mention"
: "unread"
: undefined) as "mention" | "unread" | undefined,
alertCount,
};
});
return {
...server,
unread: (typeof server.channels.find((x) =>
unreadChannels.includes(x),
) !== "undefined"
? alertCount > 0
? "mention"
: "unread"
: undefined) as "mention" | "unread" | undefined,
alertCount,
};
});
const path = useLocation().pathname;
const { server: server_id } = useParams<{ server?: string }>();
const server = servers.find((x) => x!._id == server_id);
const path = useLocation().pathname;
const { server: server_id } = useParams<{ server?: string }>();
const server = servers.find((x) => x!._id == server_id);
const { openScreen } = useIntermediate();
const { openScreen } = useIntermediate();
let homeUnread: "mention" | "unread" | undefined;
let alertCount = 0;
for (let x of channels) {
if (
((x.channel_type === "DirectMessage" && x.active) ||
x.channel_type === "Group") &&
x.unread
) {
homeUnread = "unread";
alertCount += x.alertCount ?? 0;
}
}
let homeUnread: "mention" | "unread" | undefined;
let alertCount = 0;
for (let x of channels) {
if (
((x.channel_type === "DirectMessage" && x.active) ||
x.channel_type === "Group") &&
x.unread
) {
homeUnread = "unread";
alertCount += x.alertCount ?? 0;
}
}
if (alertCount > 0) homeUnread = "mention";
const homeActive =
typeof server === "undefined" && !path.startsWith("/invite");
if (alertCount > 0) homeUnread = "mention";
const homeActive =
typeof server === "undefined" && !path.startsWith("/invite");
return (
<ServersBase>
<ServerList>
<ConditionalLink
active={homeActive}
to={lastOpened.home ? `/channel/${lastOpened.home}` : "/"}>
<ServerEntry home active={homeActive}>
<div
onContextMenu={attachContextMenu("Status")}
onClick={() =>
homeActive && openContextMenu("Status")
}>
<Icon size={42} unread={homeUnread}>
<UserIcon target={self} size={32} status />
</Icon>
</div>
<span />
</ServerEntry>
</ConditionalLink>
<LineDivider />
{servers.map((entry) => {
const active = entry!._id === server?._id;
const id = lastOpened[entry!._id];
return (
<ServersBase>
<ServerList>
<ConditionalLink
active={homeActive}
to={lastOpened.home ? `/channel/${lastOpened.home}` : "/"}>
<ServerEntry home active={homeActive}>
<div
onContextMenu={attachContextMenu("Status")}
onClick={() =>
homeActive && openContextMenu("Status")
}>
<Icon size={42} unread={homeUnread}>
<UserIcon target={self} size={32} status />
</Icon>
</div>
<span />
</ServerEntry>
</ConditionalLink>
<LineDivider />
{servers.map((entry) => {
const active = entry!._id === server?._id;
const id = lastOpened[entry!._id];
return (
<ConditionalLink
active={active}
to={
`/server/${entry!._id}` +
(id ? `/channel/${id}` : "")
}>
<ServerEntry
active={active}
onContextMenu={attachContextMenu("Menu", {
server: entry!._id,
})}>
<Tooltip content={entry.name} placement="right">
<Icon size={42} unread={entry.unread}>
<ServerIcon size={32} target={entry} />
</Icon>
</Tooltip>
<span />
</ServerEntry>
</ConditionalLink>
);
})}
<IconButton
onClick={() =>
openScreen({
id: "special_input",
type: "create_server",
})
}>
<Plus size={36} />
</IconButton>
<PaintCounter small />
</ServerList>
</ServersBase>
);
return (
<ConditionalLink
active={active}
to={
`/server/${entry!._id}` +
(id ? `/channel/${id}` : "")
}>
<ServerEntry
active={active}
onContextMenu={attachContextMenu("Menu", {
server: entry!._id,
})}>
<Tooltip content={entry.name} placement="right">
<Icon size={42} unread={entry.unread}>
<ServerIcon size={32} target={entry} />
</Icon>
</Tooltip>
<span />
</ServerEntry>
</ConditionalLink>
);
})}
<IconButton
onClick={() =>
openScreen({
id: "special_input",
type: "create_server",
})
}>
<Plus size={36} />
</IconButton>
<PaintCounter small />
</ServerList>
</ServersBase>
);
}
export default connectState(ServerListSidebar, (state) => {
return {
unreads: state.unreads,
lastOpened: state.lastOpened,
};
return {
unreads: state.unreads,
lastOpened: state.lastOpened,
};
});

View file

@ -13,9 +13,9 @@ import { connectState } from "../../../redux/connector";
import { Unreads } from "../../../redux/reducers/unreads";
import {
useChannels,
useForceUpdate,
useServer,
useChannels,
useForceUpdate,
useServer,
} from "../../../context/revoltjs/hooks";
import CollapsibleSection from "../../common/CollapsibleSection";
@ -27,124 +27,124 @@ import { ChannelButton } from "../items/ButtonItem";
import ConnectionStatus from "../items/ConnectionStatus";
interface Props {
unreads: Unreads;
unreads: Unreads;
}
const ServerBase = styled.div`
height: 100%;
width: 240px;
display: flex;
flex-shrink: 0;
flex-direction: column;
background: var(--secondary-background);
height: 100%;
width: 240px;
display: flex;
flex-shrink: 0;
flex-direction: column;
background: var(--secondary-background);
border-start-start-radius: 8px;
border-end-start-radius: 8px;
overflow: hidden;
border-start-start-radius: 8px;
border-end-start-radius: 8px;
overflow: hidden;
`;
const ServerList = styled.div`
padding: 6px;
flex-grow: 1;
overflow-y: scroll;
padding: 6px;
flex-grow: 1;
overflow-y: scroll;
> svg {
width: 100%;
}
> svg {
width: 100%;
}
`;
function ServerSidebar(props: Props) {
const { server: server_id, channel: channel_id } =
useParams<{ server?: string; channel?: string }>();
const ctx = useForceUpdate();
const { server: server_id, channel: channel_id } =
useParams<{ server?: string; channel?: string }>();
const ctx = useForceUpdate();
const server = useServer(server_id, ctx);
if (!server) return <Redirect to="/" />;
const server = useServer(server_id, ctx);
if (!server) return <Redirect to="/" />;
const channels = (
useChannels(server.channels, ctx).filter(
(entry) => typeof entry !== "undefined",
) as Readonly<Channels.TextChannel | Channels.VoiceChannel>[]
).map((x) => mapChannelWithUnread(x, props.unreads));
const channels = (
useChannels(server.channels, ctx).filter(
(entry) => typeof entry !== "undefined",
) as Readonly<Channels.TextChannel | Channels.VoiceChannel>[]
).map((x) => mapChannelWithUnread(x, props.unreads));
const channel = channels.find((x) => x?._id === channel_id);
if (channel_id && !channel) return <Redirect to={`/server/${server_id}`} />;
if (channel) useUnreads({ ...props, channel }, ctx);
const channel = channels.find((x) => x?._id === channel_id);
if (channel_id && !channel) return <Redirect to={`/server/${server_id}`} />;
if (channel) useUnreads({ ...props, channel }, ctx);
useEffect(() => {
if (!channel_id) return;
useEffect(() => {
if (!channel_id) return;
dispatch({
type: "LAST_OPENED_SET",
parent: server_id!,
child: channel_id!,
});
}, [channel_id]);
dispatch({
type: "LAST_OPENED_SET",
parent: server_id!,
child: channel_id!,
});
}, [channel_id]);
let uncategorised = new Set(server.channels);
let elements = [];
let uncategorised = new Set(server.channels);
let elements = [];
function addChannel(id: string) {
const entry = channels.find((x) => x._id === id);
if (!entry) return;
function addChannel(id: string) {
const entry = channels.find((x) => x._id === id);
if (!entry) return;
const active = channel?._id === entry._id;
const active = channel?._id === entry._id;
return (
<ConditionalLink
key={entry._id}
active={active}
to={`/server/${server!._id}/channel/${entry._id}`}>
<ChannelButton
channel={entry}
active={active}
alert={entry.unread}
compact
/>
</ConditionalLink>
);
}
return (
<ConditionalLink
key={entry._id}
active={active}
to={`/server/${server!._id}/channel/${entry._id}`}>
<ChannelButton
channel={entry}
active={active}
alert={entry.unread}
compact
/>
</ConditionalLink>
);
}
if (server.categories) {
for (let category of server.categories) {
let channels = [];
for (let id of category.channels) {
uncategorised.delete(id);
channels.push(addChannel(id));
}
if (server.categories) {
for (let category of server.categories) {
let channels = [];
for (let id of category.channels) {
uncategorised.delete(id);
channels.push(addChannel(id));
}
elements.push(
<CollapsibleSection
id={`category_${category.id}`}
defaultValue
summary={<Category text={category.title} />}>
{channels}
</CollapsibleSection>,
);
}
}
elements.push(
<CollapsibleSection
id={`category_${category.id}`}
defaultValue
summary={<Category text={category.title} />}>
{channels}
</CollapsibleSection>,
);
}
}
for (let id of Array.from(uncategorised).reverse()) {
elements.unshift(addChannel(id));
}
for (let id of Array.from(uncategorised).reverse()) {
elements.unshift(addChannel(id));
}
return (
<ServerBase>
<ServerHeader server={server} ctx={ctx} />
<ConnectionStatus />
<ServerList
onContextMenu={attachContextMenu("Menu", {
server_list: server._id,
})}>
{elements}
</ServerList>
<PaintCounter small />
</ServerBase>
);
return (
<ServerBase>
<ServerHeader server={server} ctx={ctx} />
<ConnectionStatus />
<ServerList
onContextMenu={attachContextMenu("Menu", {
server_list: server._id,
})}>
{elements}
</ServerList>
<PaintCounter small />
</ServerBase>
);
}
export default connectState(ServerSidebar, (state) => {
return {
unreads: state.unreads,
};
return {
unreads: state.unreads,
};
});

View file

@ -8,96 +8,96 @@ import { Unreads } from "../../../redux/reducers/unreads";
import { HookContext, useForceUpdate } from "../../../context/revoltjs/hooks";
type UnreadProps = {
channel: Channel;
unreads: Unreads;
channel: Channel;
unreads: Unreads;
};
export function useUnreads(
{ channel, unreads }: UnreadProps,
context?: HookContext,
{ channel, unreads }: UnreadProps,
context?: HookContext,
) {
const ctx = useForceUpdate(context);
const ctx = useForceUpdate(context);
useLayoutEffect(() => {
function checkUnread(target?: Channel) {
if (!target) return;
if (target._id !== channel._id) return;
if (
target.channel_type === "SavedMessages" ||
target.channel_type === "VoiceChannel"
)
return;
useLayoutEffect(() => {
function checkUnread(target?: Channel) {
if (!target) return;
if (target._id !== channel._id) return;
if (
target.channel_type === "SavedMessages" ||
target.channel_type === "VoiceChannel"
)
return;
const unread = unreads[channel._id]?.last_id;
if (target.last_message) {
const message =
typeof target.last_message === "string"
? target.last_message
: target.last_message._id;
if (!unread || (unread && message.localeCompare(unread) > 0)) {
dispatch({
type: "UNREADS_MARK_READ",
channel: channel._id,
message,
});
const unread = unreads[channel._id]?.last_id;
if (target.last_message) {
const message =
typeof target.last_message === "string"
? target.last_message
: target.last_message._id;
if (!unread || (unread && message.localeCompare(unread) > 0)) {
dispatch({
type: "UNREADS_MARK_READ",
channel: channel._id,
message,
});
ctx.client.req(
"PUT",
`/channels/${channel._id}/ack/${message}` as "/channels/id/ack/id",
);
}
}
}
ctx.client.req(
"PUT",
`/channels/${channel._id}/ack/${message}` as "/channels/id/ack/id",
);
}
}
}
checkUnread(channel);
checkUnread(channel);
ctx.client.channels.addListener("mutation", checkUnread);
return () =>
ctx.client.channels.removeListener("mutation", checkUnread);
}, [channel, unreads]);
ctx.client.channels.addListener("mutation", checkUnread);
return () =>
ctx.client.channels.removeListener("mutation", checkUnread);
}, [channel, unreads]);
}
export function mapChannelWithUnread(channel: Channel, unreads: Unreads) {
let last_message_id;
if (
channel.channel_type === "DirectMessage" ||
channel.channel_type === "Group"
) {
last_message_id = channel.last_message?._id;
} else if (channel.channel_type === "TextChannel") {
last_message_id = channel.last_message;
} else {
return {
...channel,
unread: undefined,
alertCount: undefined,
timestamp: channel._id,
};
}
let last_message_id;
if (
channel.channel_type === "DirectMessage" ||
channel.channel_type === "Group"
) {
last_message_id = channel.last_message?._id;
} else if (channel.channel_type === "TextChannel") {
last_message_id = channel.last_message;
} else {
return {
...channel,
unread: undefined,
alertCount: undefined,
timestamp: channel._id,
};
}
let unread: "mention" | "unread" | undefined;
let alertCount: undefined | number;
if (last_message_id && unreads) {
const u = unreads[channel._id];
if (u) {
if (u.mentions && u.mentions.length > 0) {
alertCount = u.mentions.length;
unread = "mention";
} else if (
u.last_id &&
last_message_id.localeCompare(u.last_id) > 0
) {
unread = "unread";
}
} else {
unread = "unread";
}
}
let unread: "mention" | "unread" | undefined;
let alertCount: undefined | number;
if (last_message_id && unreads) {
const u = unreads[channel._id];
if (u) {
if (u.mentions && u.mentions.length > 0) {
alertCount = u.mentions.length;
unread = "mention";
} else if (
u.last_id &&
last_message_id.localeCompare(u.last_id) > 0
) {
unread = "unread";
}
} else {
unread = "unread";
}
}
return {
...channel,
timestamp: last_message_id ?? channel._id,
unread,
alertCount,
};
return {
...channel,
timestamp: last_message_id ?? channel._id,
unread,
alertCount,
};
}

View file

@ -1,40 +1,40 @@
import { useRenderState } from "../../../lib/renderer/Singleton";
interface Props {
id: string;
id: string;
}
export function ChannelDebugInfo({ id }: Props) {
if (process.env.NODE_ENV !== "development") return null;
let view = useRenderState(id);
if (!view) return null;
if (process.env.NODE_ENV !== "development") return null;
let view = useRenderState(id);
if (!view) return null;
return (
<span style={{ display: "block", padding: "12px 10px 0 10px" }}>
<span
style={{
display: "block",
fontSize: "12px",
textTransform: "uppercase",
fontWeight: "600",
}}>
Channel Info
</span>
<p style={{ fontSize: "10px", userSelect: "text" }}>
State: <b>{view.type}</b> <br />
{view.type === "RENDER" && view.messages.length > 0 && (
<>
Start: <b>{view.messages[0]._id}</b> <br />
End:{" "}
<b>
{view.messages[view.messages.length - 1]._id}
</b>{" "}
<br />
At Top: <b>{view.atTop ? "Yes" : "No"}</b> <br />
At Bottom: <b>{view.atBottom ? "Yes" : "No"}</b>
</>
)}
</p>
</span>
);
return (
<span style={{ display: "block", padding: "12px 10px 0 10px" }}>
<span
style={{
display: "block",
fontSize: "12px",
textTransform: "uppercase",
fontWeight: "600",
}}>
Channel Info
</span>
<p style={{ fontSize: "10px", userSelect: "text" }}>
State: <b>{view.type}</b> <br />
{view.type === "RENDER" && view.messages.length > 0 && (
<>
Start: <b>{view.messages[0]._id}</b> <br />
End:{" "}
<b>
{view.messages[view.messages.length - 1]._id}
</b>{" "}
<br />
At Top: <b>{view.atTop ? "Yes" : "No"}</b> <br />
At Bottom: <b>{view.atBottom ? "Yes" : "No"}</b>
</>
)}
</p>
</span>
);
}

View file

@ -8,15 +8,15 @@ import { useContext, useEffect, useState } from "preact/hooks";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import {
AppContext,
ClientStatus,
StatusContext,
AppContext,
ClientStatus,
StatusContext,
} from "../../../context/revoltjs/RevoltClient";
import {
HookContext,
useChannel,
useForceUpdate,
useUsers,
HookContext,
useChannel,
useForceUpdate,
useUsers,
} from "../../../context/revoltjs/hooks";
import Category from "../../ui/Category";
@ -28,35 +28,35 @@ import { UserButton } from "../items/ButtonItem";
import { ChannelDebugInfo } from "./ChannelDebugInfo";
interface Props {
ctx: HookContext;
ctx: HookContext;
}
export default function MemberSidebar(props: { channel?: Channels.Channel }) {
const ctx = useForceUpdate();
const { channel: cid } = useParams<{ channel: string }>();
const channel = props.channel ?? useChannel(cid, ctx);
const ctx = useForceUpdate();
const { channel: cid } = useParams<{ channel: string }>();
const channel = props.channel ?? useChannel(cid, ctx);
switch (channel?.channel_type) {
case "Group":
return <GroupMemberSidebar channel={channel} ctx={ctx} />;
case "TextChannel":
return <ServerMemberSidebar channel={channel} ctx={ctx} />;
default:
return null;
}
switch (channel?.channel_type) {
case "Group":
return <GroupMemberSidebar channel={channel} ctx={ctx} />;
case "TextChannel":
return <ServerMemberSidebar channel={channel} ctx={ctx} />;
default:
return null;
}
}
export function GroupMemberSidebar({
channel,
ctx,
channel,
ctx,
}: Props & { channel: Channels.GroupChannel }) {
const { openScreen } = useIntermediate();
const users = useUsers(undefined, ctx);
let members = channel.recipients
.map((x) => users.find((y) => y?._id === x))
.filter((x) => typeof x !== "undefined") as User[];
const { openScreen } = useIntermediate();
const users = useUsers(undefined, ctx);
let members = channel.recipients
.map((x) => users.find((y) => y?._id === x))
.filter((x) => typeof x !== "undefined") as User[];
/*const voice = useContext(VoiceContext);
/*const voice = useContext(VoiceContext);
const voiceActive = voice.roomId === channel._id;
let voiceParticipants: User[] = [];
@ -71,32 +71,32 @@ export function GroupMemberSidebar({
voiceParticipants.sort((a, b) => a.username.localeCompare(b.username));
}*/
members.sort((a, b) => {
// ! FIXME: should probably rewrite all this code
let l =
+(
(a.online && a.status?.presence !== Users.Presence.Invisible) ??
false
) | 0;
let r =
+(
(b.online && b.status?.presence !== Users.Presence.Invisible) ??
false
) | 0;
members.sort((a, b) => {
// ! FIXME: should probably rewrite all this code
let l =
+(
(a.online && a.status?.presence !== Users.Presence.Invisible) ??
false
) | 0;
let r =
+(
(b.online && b.status?.presence !== Users.Presence.Invisible) ??
false
) | 0;
let n = r - l;
if (n !== 0) {
return n;
}
let n = r - l;
if (n !== 0) {
return n;
}
return a.username.localeCompare(b.username);
});
return a.username.localeCompare(b.username);
});
return (
<GenericSidebarBase>
<GenericSidebarList>
<ChannelDebugInfo id={channel._id} />
{/*voiceActive && voiceParticipants.length !== 0 && (
return (
<GenericSidebarBase>
<GenericSidebarList>
<ChannelDebugInfo id={channel._id} />
{/*voiceActive && voiceParticipants.length !== 0 && (
<Fragment>
<Category
type="members"
@ -121,146 +121,146 @@ export function GroupMemberSidebar({
)}
</Fragment>
)*/}
{!((members.length === 0) /*&& voiceActive*/) && (
<Category
variant="uniform"
text={
<span>
<Text id="app.main.categories.members" /> {" "}
{channel.recipients.length}
</span>
}
/>
)}
{members.length === 0 && (
/*!voiceActive &&*/ <img src={placeholderSVG} />
)}
{members.map(
(user) =>
user && (
<UserButton
key={user._id}
user={user}
context={channel}
onClick={() =>
openScreen({
id: "profile",
user_id: user._id,
})
}
/>
),
)}
</GenericSidebarList>
</GenericSidebarBase>
);
{!((members.length === 0) /*&& voiceActive*/) && (
<Category
variant="uniform"
text={
<span>
<Text id="app.main.categories.members" /> {" "}
{channel.recipients.length}
</span>
}
/>
)}
{members.length === 0 && (
/*!voiceActive &&*/ <img src={placeholderSVG} />
)}
{members.map(
(user) =>
user && (
<UserButton
key={user._id}
user={user}
context={channel}
onClick={() =>
openScreen({
id: "profile",
user_id: user._id,
})
}
/>
),
)}
</GenericSidebarList>
</GenericSidebarBase>
);
}
export function ServerMemberSidebar({
channel,
ctx,
channel,
ctx,
}: Props & { channel: Channels.TextChannel }) {
const [members, setMembers] = useState<Servers.Member[] | undefined>(
undefined,
);
const users = useUsers(members?.map((x) => x._id.user) ?? []).filter(
(x) => typeof x !== "undefined",
ctx,
) as Users.User[];
const { openScreen } = useIntermediate();
const status = useContext(StatusContext);
const client = useContext(AppContext);
const [members, setMembers] = useState<Servers.Member[] | undefined>(
undefined,
);
const users = useUsers(members?.map((x) => x._id.user) ?? []).filter(
(x) => typeof x !== "undefined",
ctx,
) as Users.User[];
const { openScreen } = useIntermediate();
const status = useContext(StatusContext);
const client = useContext(AppContext);
useEffect(() => {
if (status === ClientStatus.ONLINE && typeof members === "undefined") {
client.servers.members
.fetchMembers(channel.server)
.then((members) => setMembers(members));
}
}, [status]);
useEffect(() => {
if (status === ClientStatus.ONLINE && typeof members === "undefined") {
client.servers.members
.fetchMembers(channel.server)
.then((members) => setMembers(members));
}
}, [status]);
// ! FIXME: temporary code
useEffect(() => {
function onPacket(packet: ClientboundNotification) {
if (!members) return;
if (packet.type === "ServerMemberJoin") {
if (packet.id !== channel.server) return;
setMembers([
...members,
{ _id: { server: packet.id, user: packet.user } },
]);
} else if (packet.type === "ServerMemberLeave") {
if (packet.id !== channel.server) return;
setMembers(
members.filter(
(x) =>
!(
x._id.user === packet.user &&
x._id.server === packet.id
),
),
);
}
}
// ! FIXME: temporary code
useEffect(() => {
function onPacket(packet: ClientboundNotification) {
if (!members) return;
if (packet.type === "ServerMemberJoin") {
if (packet.id !== channel.server) return;
setMembers([
...members,
{ _id: { server: packet.id, user: packet.user } },
]);
} else if (packet.type === "ServerMemberLeave") {
if (packet.id !== channel.server) return;
setMembers(
members.filter(
(x) =>
!(
x._id.user === packet.user &&
x._id.server === packet.id
),
),
);
}
}
client.addListener("packet", onPacket);
return () => client.removeListener("packet", onPacket);
}, [members]);
client.addListener("packet", onPacket);
return () => client.removeListener("packet", onPacket);
}, [members]);
// copy paste from above
users.sort((a, b) => {
// ! FIXME: should probably rewrite all this code
let l =
+(
(a.online && a.status?.presence !== Users.Presence.Invisible) ??
false
) | 0;
let r =
+(
(b.online && b.status?.presence !== Users.Presence.Invisible) ??
false
) | 0;
// copy paste from above
users.sort((a, b) => {
// ! FIXME: should probably rewrite all this code
let l =
+(
(a.online && a.status?.presence !== Users.Presence.Invisible) ??
false
) | 0;
let r =
+(
(b.online && b.status?.presence !== Users.Presence.Invisible) ??
false
) | 0;
let n = r - l;
if (n !== 0) {
return n;
}
let n = r - l;
if (n !== 0) {
return n;
}
return a.username.localeCompare(b.username);
});
return a.username.localeCompare(b.username);
});
return (
<GenericSidebarBase>
<GenericSidebarList>
<ChannelDebugInfo id={channel._id} />
<Category
variant="uniform"
text={
<span>
<Text id="app.main.categories.members" /> {" "}
{users.length}
</span>
}
/>
{!members && <Preloader type="ring" />}
{members && users.length === 0 && <img src={placeholderSVG} />}
{users.map(
(user) =>
user && (
<UserButton
key={user._id}
user={user}
context={channel}
onClick={() =>
openScreen({
id: "profile",
user_id: user._id,
})
}
/>
),
)}
</GenericSidebarList>
</GenericSidebarBase>
);
return (
<GenericSidebarBase>
<GenericSidebarList>
<ChannelDebugInfo id={channel._id} />
<Category
variant="uniform"
text={
<span>
<Text id="app.main.categories.members" /> {" "}
{users.length}
</span>
}
/>
{!members && <Preloader type="ring" />}
{members && users.length === 0 && <img src={placeholderSVG} />}
{users.map(
(user) =>
user && (
<UserButton
key={user._id}
user={user}
context={channel}
onClick={() =>
openScreen({
id: "profile",
user_id: user._id,
})
}
/>
),
)}
</GenericSidebarList>
</GenericSidebarBase>
);
}

View file

@ -1,10 +1,10 @@
import styled from "styled-components";
export default styled.div`
padding: 8px;
font-size: 14px;
text-align: center;
padding: 8px;
font-size: 14px;
text-align: center;
color: var(--accent);
background: var(--primary-background);
color: var(--accent);
background: var(--primary-background);
`;

View file

@ -1,71 +1,71 @@
import styled, { css } from "styled-components";
interface Props {
readonly contrast?: boolean;
readonly error?: boolean;
readonly contrast?: boolean;
readonly error?: boolean;
}
export default styled.button<Props>`
z-index: 1;
padding: 8px;
font-size: 16px;
text-align: center;
font-family: inherit;
z-index: 1;
padding: 8px;
font-size: 16px;
text-align: center;
font-family: inherit;
transition: 0.2s ease opacity;
transition: 0.2s ease background-color;
transition: 0.2s ease opacity;
transition: 0.2s ease background-color;
background: var(--primary-background);
color: var(--foreground);
background: var(--primary-background);
color: var(--foreground);
border-radius: 6px;
cursor: pointer;
border: none;
border-radius: 6px;
cursor: pointer;
border: none;
&:hover {
background: var(--secondary-header);
}
&:hover {
background: var(--secondary-header);
}
&:disabled {
background: var(--primary-background);
}
&:disabled {
background: var(--primary-background);
}
&:active {
background: var(--secondary-background);
}
&:active {
background: var(--secondary-background);
}
${(props) =>
props.contrast &&
css`
padding: 4px 8px;
background: var(--secondary-header);
${(props) =>
props.contrast &&
css`
padding: 4px 8px;
background: var(--secondary-header);
&:hover {
background: var(--primary-header);
}
&:hover {
background: var(--primary-header);
}
&:disabled {
background: var(--secondary-header);
}
&:disabled {
background: var(--secondary-header);
}
&:active {
background: var(--secondary-background);
}
`}
&:active {
background: var(--secondary-background);
}
`}
${(props) =>
props.error &&
css`
color: white;
background: var(--error);
${(props) =>
props.error &&
css`
color: white;
background: var(--error);
&:hover {
filter: brightness(1.2);
background: var(--error);
}
&:hover {
filter: brightness(1.2);
background: var(--error);
}
&:disabled {
background: var(--error);
}
`}
&:disabled {
background: var(--error);
}
`}
`;

View file

@ -4,52 +4,52 @@ import styled, { css } from "styled-components";
import { Children } from "../../types/Preact";
const CategoryBase = styled.div<Pick<Props, "variant">>`
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
margin-top: 4px;
padding: 6px 0;
margin-bottom: 4px;
white-space: nowrap;
margin-top: 4px;
padding: 6px 0;
margin-bottom: 4px;
white-space: nowrap;
display: flex;
align-items: center;
flex-direction: row;
justify-content: space-between;
display: flex;
align-items: center;
flex-direction: row;
justify-content: space-between;
svg {
cursor: pointer;
}
svg {
cursor: pointer;
}
&:first-child {
margin-top: 0;
padding-top: 0;
}
&:first-child {
margin-top: 0;
padding-top: 0;
}
${(props) =>
props.variant === "uniform" &&
css`
padding-top: 6px;
`}
${(props) =>
props.variant === "uniform" &&
css`
padding-top: 6px;
`}
`;
type Props = Omit<
JSX.HTMLAttributes<HTMLDivElement>,
"children" | "as" | "action"
JSX.HTMLAttributes<HTMLDivElement>,
"children" | "as" | "action"
> & {
text: Children;
action?: () => void;
variant?: "default" | "uniform";
text: Children;
action?: () => void;
variant?: "default" | "uniform";
};
export default function Category(props: Props) {
let { text, action, ...otherProps } = props;
let { text, action, ...otherProps } = props;
return (
<CategoryBase {...otherProps}>
{text}
{action && <Plus size={16} onClick={action} />}
</CategoryBase>
);
return (
<CategoryBase {...otherProps}>
{text}
{action && <Plus size={16} onClick={action} />}
</CategoryBase>
);
}

View file

@ -4,105 +4,105 @@ import styled, { css } from "styled-components";
import { Children } from "../../types/Preact";
const CheckboxBase = styled.label`
margin-top: 20px;
gap: 4px;
z-index: 1;
display: flex;
border-radius: 4px;
align-items: center;
margin-top: 20px;
gap: 4px;
z-index: 1;
display: flex;
border-radius: 4px;
align-items: center;
cursor: pointer;
font-size: 18px;
user-select: none;
cursor: pointer;
font-size: 18px;
user-select: none;
transition: 0.2s ease all;
transition: 0.2s ease all;
input {
display: none;
}
input {
display: none;
}
&:hover {
.check {
background: var(--background);
}
}
&:hover {
.check {
background: var(--background);
}
}
&[disabled] {
opacity: 0.5;
cursor: not-allowed;
&[disabled] {
opacity: 0.5;
cursor: not-allowed;
&:hover {
background: unset;
}
}
&:hover {
background: unset;
}
}
`;
const CheckboxContent = styled.span`
display: flex;
flex-grow: 1;
font-size: 1rem;
font-weight: 600;
flex-direction: column;
display: flex;
flex-grow: 1;
font-size: 1rem;
font-weight: 600;
flex-direction: column;
`;
const CheckboxDescription = styled.span`
font-size: 0.75rem;
font-weight: 400;
color: var(--secondary-foreground);
font-size: 0.75rem;
font-weight: 400;
color: var(--secondary-foreground);
`;
const Checkmark = styled.div<{ checked: boolean }>`
margin: 4px;
width: 24px;
height: 24px;
display: grid;
flex-shrink: 0;
border-radius: 4px;
place-items: center;
transition: 0.2s ease all;
background: var(--secondary-background);
margin: 4px;
width: 24px;
height: 24px;
display: grid;
flex-shrink: 0;
border-radius: 4px;
place-items: center;
transition: 0.2s ease all;
background: var(--secondary-background);
svg {
color: var(--secondary-background);
}
svg {
color: var(--secondary-background);
}
${(props) =>
props.checked &&
css`
background: var(--accent) !important;
`}
${(props) =>
props.checked &&
css`
background: var(--accent) !important;
`}
`;
export interface CheckboxProps {
checked: boolean;
disabled?: boolean;
className?: string;
children: Children;
description?: Children;
onChange: (state: boolean) => void;
checked: boolean;
disabled?: boolean;
className?: string;
children: Children;
description?: Children;
onChange: (state: boolean) => void;
}
export default function Checkbox(props: CheckboxProps) {
return (
<CheckboxBase disabled={props.disabled} className={props.className}>
<CheckboxContent>
<span>{props.children}</span>
{props.description && (
<CheckboxDescription>
{props.description}
</CheckboxDescription>
)}
</CheckboxContent>
<input
type="checkbox"
checked={props.checked}
onChange={() =>
!props.disabled && props.onChange(!props.checked)
}
/>
<Checkmark checked={props.checked} className="check">
<Check size={20} />
</Checkmark>
</CheckboxBase>
);
return (
<CheckboxBase disabled={props.disabled} className={props.className}>
<CheckboxContent>
<span>{props.children}</span>
{props.description && (
<CheckboxDescription>
{props.description}
</CheckboxDescription>
)}
</CheckboxContent>
<input
type="checkbox"
checked={props.checked}
onChange={() =>
!props.disabled && props.onChange(!props.checked)
}
/>
<Checkmark checked={props.checked} className="check">
<Check size={20} />
</Checkmark>
</CheckboxBase>
);
}

View file

@ -5,123 +5,123 @@ import styled, { css } from "styled-components";
import { useRef } from "preact/hooks";
interface Props {
value: string;
onChange: (value: string) => void;
value: string;
onChange: (value: string) => void;
}
const presets = [
[
"#7B68EE",
"#3498DB",
"#1ABC9C",
"#F1C40F",
"#FF7F50",
"#FD6671",
"#E91E63",
"#D468EE",
],
[
"#594CAD",
"#206694",
"#11806A",
"#C27C0E",
"#CD5B45",
"#FF424F",
"#AD1457",
"#954AA8",
],
[
"#7B68EE",
"#3498DB",
"#1ABC9C",
"#F1C40F",
"#FF7F50",
"#FD6671",
"#E91E63",
"#D468EE",
],
[
"#594CAD",
"#206694",
"#11806A",
"#C27C0E",
"#CD5B45",
"#FF424F",
"#AD1457",
"#954AA8",
],
];
const SwatchesBase = styled.div`
gap: 8px;
display: flex;
gap: 8px;
display: flex;
input {
opacity: 0;
margin-top: 44px;
position: absolute;
pointer-events: none;
}
input {
opacity: 0;
margin-top: 44px;
position: absolute;
pointer-events: none;
}
`;
const Swatch = styled.div<{ type: "small" | "large"; colour: string }>`
flex-shrink: 0;
cursor: pointer;
border-radius: 4px;
background-color: ${(props) => props.colour};
flex-shrink: 0;
cursor: pointer;
border-radius: 4px;
background-color: ${(props) => props.colour};
display: grid;
place-items: center;
display: grid;
place-items: center;
&:hover {
border: 3px solid var(--foreground);
transition: border ease-in-out 0.07s;
}
&:hover {
border: 3px solid var(--foreground);
transition: border ease-in-out 0.07s;
}
svg {
color: white;
}
svg {
color: white;
}
${(props) =>
props.type === "small"
? css`
width: 30px;
height: 30px;
${(props) =>
props.type === "small"
? css`
width: 30px;
height: 30px;
svg {
/*stroke-width: 2;*/
}
`
: css`
width: 68px;
height: 68px;
`}
svg {
/*stroke-width: 2;*/
}
`
: css`
width: 68px;
height: 68px;
`}
`;
const Rows = styled.div`
gap: 8px;
display: flex;
flex-direction: column;
gap: 8px;
display: flex;
flex-direction: column;
> div {
gap: 8px;
display: flex;
flex-direction: row;
}
> div {
gap: 8px;
display: flex;
flex-direction: row;
}
`;
export default function ColourSwatches({ value, onChange }: Props) {
const ref = useRef<HTMLInputElement>();
const ref = useRef<HTMLInputElement>();
return (
<SwatchesBase>
<Swatch
colour={value}
type="large"
onClick={() => ref.current.click()}>
<Palette size={32} />
</Swatch>
<input
type="color"
value={value}
ref={ref}
onChange={(ev) => onChange(ev.currentTarget.value)}
/>
<Rows>
{presets.map((row, i) => (
<div key={i}>
{row.map((swatch, i) => (
<Swatch
colour={swatch}
type="small"
key={i}
onClick={() => onChange(swatch)}>
{swatch === value && <Check size={18} />}
</Swatch>
))}
</div>
))}
</Rows>
</SwatchesBase>
);
return (
<SwatchesBase>
<Swatch
colour={value}
type="large"
onClick={() => ref.current.click()}>
<Palette size={32} />
</Swatch>
<input
type="color"
value={value}
ref={ref}
onChange={(ev) => onChange(ev.currentTarget.value)}
/>
<Rows>
{presets.map((row, i) => (
<div key={i}>
{row.map((swatch, i) => (
<Swatch
colour={swatch}
type="small"
key={i}
onClick={() => onChange(swatch)}>
{swatch === value && <Check size={18} />}
</Swatch>
))}
</div>
))}
</Rows>
</SwatchesBase>
);
}

View file

@ -1,20 +1,20 @@
import styled from "styled-components";
export default styled.select`
padding: 8px;
border-radius: 6px;
font-family: inherit;
color: var(--secondary-foreground);
background: var(--secondary-background);
font-size: 0.875rem;
border: none;
outline: 2px solid transparent;
transition: outline-color 0.2s ease-in-out;
transition: box-shadow 0.3s;
cursor: pointer;
width: 100%;
padding: 8px;
border-radius: 6px;
font-family: inherit;
color: var(--secondary-foreground);
background: var(--secondary-background);
font-size: 0.875rem;
border: none;
outline: 2px solid transparent;
transition: outline-color 0.2s ease-in-out;
transition: box-shadow 0.3s;
cursor: pointer;
width: 100%;
&:focus {
box-shadow: 0 0 0 2pt var(--accent);
}
&:focus {
box-shadow: 0 0 0 2pt var(--accent);
}
`;

View file

@ -2,47 +2,47 @@ import dayjs from "dayjs";
import styled, { css } from "styled-components";
const Base = styled.div<{ unread?: boolean }>`
height: 0;
display: flex;
user-select: none;
align-items: center;
margin: 17px 12px 5px;
border-top: thin solid var(--tertiary-foreground);
height: 0;
display: flex;
user-select: none;
align-items: center;
margin: 17px 12px 5px;
border-top: thin solid var(--tertiary-foreground);
time {
margin-top: -2px;
font-size: 0.6875rem;
line-height: 0.6875rem;
padding: 2px 5px 2px 0;
color: var(--tertiary-foreground);
background: var(--primary-background);
}
time {
margin-top: -2px;
font-size: 0.6875rem;
line-height: 0.6875rem;
padding: 2px 5px 2px 0;
color: var(--tertiary-foreground);
background: var(--primary-background);
}
${(props) =>
props.unread &&
css`
border-top: thin solid var(--accent);
`}
${(props) =>
props.unread &&
css`
border-top: thin solid var(--accent);
`}
`;
const Unread = styled.div`
background: var(--accent);
color: white;
padding: 5px 8px;
border-radius: 60px;
font-weight: 600;
background: var(--accent);
color: white;
padding: 5px 8px;
border-radius: 60px;
font-weight: 600;
`;
interface Props {
date: Date;
unread?: boolean;
date: Date;
unread?: boolean;
}
export default function DateDivider(props: Props) {
return (
<Base unread={props.unread}>
{props.unread && <Unread>NEW</Unread>}
<time>{dayjs(props.date).format("LL")}</time>
</Base>
);
return (
<Base unread={props.unread}>
{props.unread && <Unread>NEW</Unread>}
<time>{dayjs(props.date).format("LL")}</time>
</Base>
);
}

View file

@ -1,74 +1,74 @@
import styled, { css } from "styled-components";
export default styled.details<{ sticky?: boolean; large?: boolean }>`
summary {
${(props) =>
props.sticky &&
css`
top: -1px;
z-index: 10;
position: sticky;
`}
summary {
${(props) =>
props.sticky &&
css`
top: -1px;
z-index: 10;
position: sticky;
`}
${(props) =>
props.large &&
css`
/*padding: 5px 0;*/
background: var(--primary-background);
color: var(--secondary-foreground);
${(props) =>
props.large &&
css`
/*padding: 5px 0;*/
background: var(--primary-background);
color: var(--secondary-foreground);
.padding {
/*TOFIX: make this applicable only for the friends list menu, DO NOT REMOVE.*/
display: flex;
align-items: center;
padding: 5px 0;
margin: 0.8em 0px 0.4em;
cursor: pointer;
}
`}
.padding {
/*TOFIX: make this applicable only for the friends list menu, DO NOT REMOVE.*/
display: flex;
align-items: center;
padding: 5px 0;
margin: 0.8em 0px 0.4em;
cursor: pointer;
}
`}
outline: none;
cursor: pointer;
list-style: none;
align-items: center;
transition: 0.2s opacity;
cursor: pointer;
list-style: none;
align-items: center;
transition: 0.2s opacity;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
&::marker,
&::-webkit-details-marker {
display: none;
}
&::marker,
&::-webkit-details-marker {
display: none;
}
.title {
flex-grow: 1;
margin-top: 1px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.title {
flex-grow: 1;
margin-top: 1px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.padding {
display: flex;
align-items: center;
.padding {
display: flex;
align-items: center;
> svg {
flex-shrink: 0;
margin-inline-end: 4px;
transition: 0.2s ease transform;
}
}
}
> svg {
flex-shrink: 0;
margin-inline-end: 4px;
transition: 0.2s ease transform;
}
}
}
&:not([open]) {
summary {
opacity: 0.7;
}
&:not([open]) {
summary {
opacity: 0.7;
}
summary svg {
transform: rotateZ(-90deg);
}
}
summary svg {
transform: rotateZ(-90deg);
}
}
`;

View file

@ -1,57 +1,57 @@
import styled, { css } from "styled-components";
interface Props {
borders?: boolean;
background?: boolean;
placement: "primary" | "secondary";
borders?: boolean;
background?: boolean;
placement: "primary" | "secondary";
}
export default styled.div<Props>`
gap: 6px;
height: 48px;
flex: 0 auto;
display: flex;
flex-shrink: 0;
padding: 0 16px;
font-weight: 600;
user-select: none;
align-items: center;
gap: 6px;
height: 48px;
flex: 0 auto;
display: flex;
flex-shrink: 0;
padding: 0 16px;
font-weight: 600;
user-select: none;
align-items: center;
background-size: cover !important;
background-position: center !important;
background-color: var(--primary-header);
background-size: cover !important;
background-position: center !important;
background-color: var(--primary-header);
svg {
flex-shrink: 0;
}
svg {
flex-shrink: 0;
}
/*@media only screen and (max-width: 768px) {
/*@media only screen and (max-width: 768px) {
padding: 0 12px;
}*/
@media (pointer: coarse) {
height: 56px;
}
${(props) =>
props.background &&
css`
height: 120px !important;
align-items: flex-end;
text-shadow: 0px 0px 1px black;
`}
${(props) =>
props.placement === "secondary" &&
css`
background-color: var(--secondary-header);
padding: 14px;
`}
@media (pointer: coarse) {
height: 56px;
}
${(props) =>
props.borders &&
css`
border-start-start-radius: 8px;
`}
props.background &&
css`
height: 120px !important;
align-items: flex-end;
text-shadow: 0px 0px 1px black;
`}
${(props) =>
props.placement === "secondary" &&
css`
background-color: var(--secondary-header);
padding: 14px;
`}
${(props) =>
props.borders &&
css`
border-start-start-radius: 8px;
`}
`;

View file

@ -1,46 +1,46 @@
import styled, { css } from "styled-components";
interface Props {
type?: "default" | "circle";
type?: "default" | "circle";
}
const normal = `var(--secondary-foreground)`;
const hover = `var(--foreground)`;
export default styled.div<Props>`
z-index: 1;
display: grid;
cursor: pointer;
place-items: center;
transition: 0.1s ease background-color;
z-index: 1;
display: grid;
cursor: pointer;
place-items: center;
transition: 0.1s ease background-color;
fill: ${normal};
color: ${normal};
/*stroke: ${normal};*/
fill: ${normal};
color: ${normal};
/*stroke: ${normal};*/
a {
color: ${normal};
}
a {
color: ${normal};
}
&:hover {
fill: ${hover};
color: ${hover};
/*stroke: ${hover};*/
&:hover {
fill: ${hover};
color: ${hover};
/*stroke: ${hover};*/
a {
color: ${hover};
}
}
a {
color: ${hover};
}
}
${(props) =>
props.type === "circle" &&
css`
padding: 4px;
border-radius: 50%;
background-color: var(--secondary-header);
${(props) =>
props.type === "circle" &&
css`
padding: 4px;
border-radius: 50%;
background-color: var(--secondary-header);
&:hover {
background-color: var(--primary-header);
}
`}
&:hover {
background-color: var(--primary-header);
}
`}
`;

View file

@ -1,39 +1,39 @@
import styled, { css } from "styled-components";
interface Props {
readonly contrast?: boolean;
readonly contrast?: boolean;
}
export default styled.input<Props>`
z-index: 1;
padding: 8px 16px;
border-radius: 6px;
z-index: 1;
padding: 8px 16px;
border-radius: 6px;
font-family: inherit;
color: var(--foreground);
background: var(--primary-background);
transition: 0.2s ease background-color;
font-family: inherit;
color: var(--foreground);
background: var(--primary-background);
transition: 0.2s ease background-color;
border: none;
outline: 2px solid transparent;
transition: outline-color 0.2s ease-in-out;
border: none;
outline: 2px solid transparent;
transition: outline-color 0.2s ease-in-out;
&:hover {
background: var(--secondary-background);
}
&:hover {
background: var(--secondary-background);
}
&:focus {
outline: 2px solid var(--accent);
}
&:focus {
outline: 2px solid var(--accent);
}
${(props) =>
props.contrast &&
css`
color: var(--secondary-foreground);
background: var(--secondary-background);
${(props) =>
props.contrast &&
css`
color: var(--secondary-foreground);
background: var(--secondary-background);
&:hover {
background: var(--hover);
}
`}
&:hover {
background: var(--hover);
}
`}
`;

View file

@ -1,9 +1,9 @@
import styled from "styled-components";
export default styled.div`
height: 0px;
opacity: 0.6;
flex-shrink: 0;
margin: 8px 10px;
border-top: 1px solid var(--tertiary-foreground);
height: 0px;
opacity: 0.6;
flex-shrink: 0;
margin: 8px 10px;
border-top: 1px solid var(--tertiary-foreground);
`;

View file

@ -1,22 +1,22 @@
// This file must be imported and used at least once for SVG masks.
export default function Masks() {
return (
<svg width={0} height={0} style={{ position: "fixed" }}>
<defs>
<mask id="server">
<rect x="0" y="0" width="32" height="32" fill="white" />
<circle cx="27" cy="5" r="7" fill={"black"} />
</mask>
<mask id="user">
<rect x="0" y="0" width="32" height="32" fill="white" />
<circle cx="27" cy="27" r="7" fill={"black"} />
</mask>
<mask id="overlap">
<rect x="0" y="0" width="32" height="32" fill="white" />
<circle cx="32" cy="16" r="18" fill={"black"} />
</mask>
</defs>
</svg>
);
return (
<svg width={0} height={0} style={{ position: "fixed" }}>
<defs>
<mask id="server">
<rect x="0" y="0" width="32" height="32" fill="white" />
<circle cx="27" cy="5" r="7" fill={"black"} />
</mask>
<mask id="user">
<rect x="0" y="0" width="32" height="32" fill="white" />
<circle cx="27" cy="27" r="7" fill={"black"} />
</mask>
<mask id="overlap">
<rect x="0" y="0" width="32" height="32" fill="white" />
<circle cx="32" cy="16" r="18" fill={"black"} />
</mask>
</defs>
</svg>
);
}

View file

@ -19,181 +19,181 @@ const zoomIn = keyframes`
`;
const ModalBase = styled.div`
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 9999;
position: fixed;
max-height: 100%;
user-select: none;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 9999;
position: fixed;
max-height: 100%;
user-select: none;
animation-name: ${open};
animation-duration: 0.2s;
animation-name: ${open};
animation-duration: 0.2s;
display: grid;
overflow-y: auto;
place-items: center;
display: grid;
overflow-y: auto;
place-items: center;
color: var(--foreground);
background: rgba(0, 0, 0, 0.8);
color: var(--foreground);
background: rgba(0, 0, 0, 0.8);
`;
const ModalContainer = styled.div`
overflow: hidden;
border-radius: 8px;
max-width: calc(100vw - 20px);
overflow: hidden;
border-radius: 8px;
max-width: calc(100vw - 20px);
animation-name: ${zoomIn};
animation-duration: 0.25s;
animation-timing-function: cubic-bezier(0.3, 0.3, 0.18, 1.1);
animation-name: ${zoomIn};
animation-duration: 0.25s;
animation-timing-function: cubic-bezier(0.3, 0.3, 0.18, 1.1);
`;
const ModalContent = styled.div<
{ [key in "attachment" | "noBackground" | "border" | "padding"]?: boolean }
{ [key in "attachment" | "noBackground" | "border" | "padding"]?: boolean }
>`
border-radius: 8px;
text-overflow: ellipsis;
border-radius: 8px;
text-overflow: ellipsis;
h3 {
margin-top: 0;
}
h3 {
margin-top: 0;
}
form {
display: flex;
flex-direction: column;
}
${(props) =>
!props.noBackground &&
css`
background: var(--secondary-header);
`}
${(props) =>
props.padding &&
css`
padding: 1.5em;
`}
form {
display: flex;
flex-direction: column;
}
${(props) =>
props.attachment &&
css`
border-radius: 8px 8px 0 0;
`}
!props.noBackground &&
css`
background: var(--secondary-header);
`}
${(props) =>
props.border &&
css`
border-radius: 10px;
border: 2px solid var(--secondary-background);
`}
props.padding &&
css`
padding: 1.5em;
`}
${(props) =>
props.attachment &&
css`
border-radius: 8px 8px 0 0;
`}
${(props) =>
props.border &&
css`
border-radius: 10px;
border: 2px solid var(--secondary-background);
`}
`;
const ModalActions = styled.div`
gap: 8px;
display: flex;
flex-direction: row-reverse;
gap: 8px;
display: flex;
flex-direction: row-reverse;
padding: 1em 1.5em;
border-radius: 0 0 8px 8px;
background: var(--secondary-background);
padding: 1em 1.5em;
border-radius: 0 0 8px 8px;
background: var(--secondary-background);
`;
export interface Action {
text: Children;
onClick: () => void;
confirmation?: boolean;
contrast?: boolean;
error?: boolean;
text: Children;
onClick: () => void;
confirmation?: boolean;
contrast?: boolean;
error?: boolean;
}
interface Props {
children?: Children;
title?: Children;
children?: Children;
title?: Children;
disallowClosing?: boolean;
noBackground?: boolean;
dontModal?: boolean;
padding?: boolean;
disallowClosing?: boolean;
noBackground?: boolean;
dontModal?: boolean;
padding?: boolean;
onClose: () => void;
actions?: Action[];
disabled?: boolean;
border?: boolean;
visible: boolean;
onClose: () => void;
actions?: Action[];
disabled?: boolean;
border?: boolean;
visible: boolean;
}
export default function Modal(props: Props) {
if (!props.visible) return null;
if (!props.visible) return null;
let content = (
<ModalContent
attachment={!!props.actions}
noBackground={props.noBackground}
border={props.border}
padding={props.padding ?? !props.dontModal}>
{props.title && <h3>{props.title}</h3>}
{props.children}
</ModalContent>
);
let content = (
<ModalContent
attachment={!!props.actions}
noBackground={props.noBackground}
border={props.border}
padding={props.padding ?? !props.dontModal}>
{props.title && <h3>{props.title}</h3>}
{props.children}
</ModalContent>
);
if (props.dontModal) {
return content;
}
if (props.dontModal) {
return content;
}
useEffect(() => {
if (props.disallowClosing) return;
useEffect(() => {
if (props.disallowClosing) return;
function keyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
props.onClose();
}
}
function keyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
props.onClose();
}
}
document.body.addEventListener("keydown", keyDown);
return () => document.body.removeEventListener("keydown", keyDown);
}, [props.disallowClosing, props.onClose]);
document.body.addEventListener("keydown", keyDown);
return () => document.body.removeEventListener("keydown", keyDown);
}, [props.disallowClosing, props.onClose]);
let confirmationAction = props.actions?.find(
(action) => action.confirmation,
);
useEffect(() => {
if (!confirmationAction) return;
let confirmationAction = props.actions?.find(
(action) => action.confirmation,
);
useEffect(() => {
if (!confirmationAction) return;
// ! FIXME: this may be done better if we
// ! can focus the button although that
// ! doesn't seem to work...
function keyDown(e: KeyboardEvent) {
if (e.key === "Enter") {
confirmationAction!.onClick();
}
}
// ! FIXME: this may be done better if we
// ! can focus the button although that
// ! doesn't seem to work...
function keyDown(e: KeyboardEvent) {
if (e.key === "Enter") {
confirmationAction!.onClick();
}
}
document.body.addEventListener("keydown", keyDown);
return () => document.body.removeEventListener("keydown", keyDown);
}, [confirmationAction]);
document.body.addEventListener("keydown", keyDown);
return () => document.body.removeEventListener("keydown", keyDown);
}, [confirmationAction]);
return createPortal(
<ModalBase
onClick={(!props.disallowClosing && props.onClose) || undefined}>
<ModalContainer onClick={(e) => (e.cancelBubble = true)}>
{content}
{props.actions && (
<ModalActions>
{props.actions.map((x) => (
<Button
contrast={x.contrast ?? true}
error={x.error ?? false}
onClick={x.onClick}
disabled={props.disabled}>
{x.text}
</Button>
))}
</ModalActions>
)}
</ModalContainer>
</ModalBase>,
document.body,
);
return createPortal(
<ModalBase
onClick={(!props.disallowClosing && props.onClose) || undefined}>
<ModalContainer onClick={(e) => (e.cancelBubble = true)}>
{content}
{props.actions && (
<ModalActions>
{props.actions.map((x) => (
<Button
contrast={x.contrast ?? true}
error={x.error ?? false}
onClick={x.onClick}
disabled={props.disabled}>
{x.text}
</Button>
))}
</ModalActions>
)}
</ModalContainer>
</ModalBase>,
document.body,
);
}

View file

@ -5,60 +5,60 @@ import { Text } from "preact-i18n";
import { Children } from "../../types/Preact";
type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, "children" | "as"> & {
error?: string;
block?: boolean;
spaced?: boolean;
children?: Children;
type?: "default" | "subtle" | "error";
error?: string;
block?: boolean;
spaced?: boolean;
children?: Children;
type?: "default" | "subtle" | "error";
};
const OverlineBase = styled.div<Omit<Props, "children" | "error">>`
display: inline;
margin: 0.4em 0;
${(props) =>
props.spaced &&
css`
margin-top: 0.8em;
`}
font-size: 14px;
font-weight: 600;
color: var(--foreground);
text-transform: uppercase;
${(props) =>
props.type === "subtle" &&
css`
font-size: 12px;
color: var(--secondary-foreground);
`}
${(props) =>
props.type === "error" &&
css`
font-size: 12px;
font-weight: 400;
color: var(--error);
`}
display: inline;
margin: 0.4em 0;
${(props) =>
props.block &&
css`
display: block;
`}
props.spaced &&
css`
margin-top: 0.8em;
`}
font-size: 14px;
font-weight: 600;
color: var(--foreground);
text-transform: uppercase;
${(props) =>
props.type === "subtle" &&
css`
font-size: 12px;
color: var(--secondary-foreground);
`}
${(props) =>
props.type === "error" &&
css`
font-size: 12px;
font-weight: 400;
color: var(--error);
`}
${(props) =>
props.block &&
css`
display: block;
`}
`;
export default function Overline(props: Props) {
return (
<OverlineBase {...props}>
{props.children}
{props.children && props.error && <> &middot; </>}
{props.error && (
<Overline type="error">
<Text id={`error.${props.error}`}>{props.error}</Text>
</Overline>
)}
</OverlineBase>
);
return (
<OverlineBase {...props}>
{props.children}
{props.children && props.error && <> &middot; </>}
{props.error && (
<Overline type="error">
<Text id={`error.${props.error}`}>{props.error}</Text>
</Overline>
)}
</OverlineBase>
);
}

View file

@ -21,83 +21,83 @@ const prRing = keyframes`
`;
const PreloaderBase = styled.div`
width: 100%;
height: 100%;
width: 100%;
height: 100%;
display: grid;
place-items: center;
display: grid;
place-items: center;
.spinner {
width: 58px;
display: flex;
text-align: center;
margin: 100px auto 0;
justify-content: space-between;
}
.spinner {
width: 58px;
display: flex;
text-align: center;
margin: 100px auto 0;
justify-content: space-between;
}
.spinner > div {
width: 14px;
height: 14px;
background-color: var(--tertiary-foreground);
.spinner > div {
width: 14px;
height: 14px;
background-color: var(--tertiary-foreground);
border-radius: 100%;
display: inline-block;
animation: ${skSpinner} 1.4s infinite ease-in-out both;
}
border-radius: 100%;
display: inline-block;
animation: ${skSpinner} 1.4s infinite ease-in-out both;
}
.spinner div:nth-child(1) {
animation-delay: -0.32s;
}
.spinner div:nth-child(1) {
animation-delay: -0.32s;
}
.spinner div:nth-child(2) {
animation-delay: -0.16s;
}
.spinner div:nth-child(2) {
animation-delay: -0.16s;
}
.ring {
display: inline-block;
position: relative;
width: 48px;
height: 52px;
}
.ring {
display: inline-block;
position: relative;
width: 48px;
height: 52px;
}
.ring div {
width: 32px;
margin: 8px;
height: 32px;
display: block;
position: absolute;
border-radius: 50%;
box-sizing: border-box;
border: 2px solid #fff;
animation: ${prRing} 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-color: #fff transparent transparent transparent;
}
.ring div {
width: 32px;
margin: 8px;
height: 32px;
display: block;
position: absolute;
border-radius: 50%;
box-sizing: border-box;
border: 2px solid #fff;
animation: ${prRing} 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-color: #fff transparent transparent transparent;
}
.ring div:nth-child(1) {
animation-delay: -0.45s;
}
.ring div:nth-child(1) {
animation-delay: -0.45s;
}
.ring div:nth-child(2) {
animation-delay: -0.3s;
}
.ring div:nth-child(2) {
animation-delay: -0.3s;
}
.ring div:nth-child(3) {
animation-delay: -0.15s;
}
.ring div:nth-child(3) {
animation-delay: -0.15s;
}
`;
interface Props {
type: "spinner" | "ring";
type: "spinner" | "ring";
}
export default function Preloader({ type }: Props) {
return (
<PreloaderBase>
<div class={type}>
<div />
<div />
<div />
</div>
</PreloaderBase>
);
return (
<PreloaderBase>
<div class={type}>
<div />
<div />
<div />
</div>
</PreloaderBase>
);
}

View file

@ -4,108 +4,108 @@ import styled, { css } from "styled-components";
import { Children } from "../../types/Preact";
interface Props {
children: Children;
description?: Children;
children: Children;
description?: Children;
checked: boolean;
disabled?: boolean;
onSelect: () => void;
checked: boolean;
disabled?: boolean;
onSelect: () => void;
}
interface BaseProps {
selected: boolean;
selected: boolean;
}
const RadioBase = styled.label<BaseProps>`
gap: 4px;
z-index: 1;
padding: 4px;
display: flex;
cursor: pointer;
align-items: center;
gap: 4px;
z-index: 1;
padding: 4px;
display: flex;
cursor: pointer;
align-items: center;
font-size: 1rem;
font-weight: 600;
user-select: none;
border-radius: 4px;
transition: 0.2s ease all;
font-size: 1rem;
font-weight: 600;
user-select: none;
border-radius: 4px;
transition: 0.2s ease all;
&:hover {
background: var(--hover);
}
&:hover {
background: var(--hover);
}
> input {
display: none;
}
> input {
display: none;
}
> div {
margin: 4px;
width: 24px;
height: 24px;
display: grid;
border-radius: 50%;
place-items: center;
background: var(--foreground);
> div {
margin: 4px;
width: 24px;
height: 24px;
display: grid;
border-radius: 50%;
place-items: center;
background: var(--foreground);
svg {
color: var(--foreground);
/*stroke-width: 2;*/
}
}
svg {
color: var(--foreground);
/*stroke-width: 2;*/
}
}
${(props) =>
props.selected &&
css`
color: white;
cursor: default;
background: var(--accent);
${(props) =>
props.selected &&
css`
color: white;
cursor: default;
background: var(--accent);
> div {
background: white;
}
> div {
background: white;
}
> div svg {
color: var(--accent);
}
> div svg {
color: var(--accent);
}
&:hover {
background: var(--accent);
}
`}
&:hover {
background: var(--accent);
}
`}
`;
const RadioDescription = styled.span<BaseProps>`
font-size: 0.8em;
font-weight: 400;
color: var(--secondary-foreground);
font-size: 0.8em;
font-weight: 400;
color: var(--secondary-foreground);
${(props) =>
props.selected &&
css`
color: white;
`}
${(props) =>
props.selected &&
css`
color: white;
`}
`;
export default function Radio(props: Props) {
return (
<RadioBase
selected={props.checked}
disabled={props.disabled}
onClick={() =>
!props.disabled && props.onSelect && props.onSelect()
}>
<div>
<Circle size={12} />
</div>
<input type="radio" checked={props.checked} />
<span>
<span>{props.children}</span>
{props.description && (
<RadioDescription selected={props.checked}>
{props.description}
</RadioDescription>
)}
</span>
</RadioBase>
);
return (
<RadioBase
selected={props.checked}
disabled={props.disabled}
onClick={() =>
!props.disabled && props.onSelect && props.onSelect()
}>
<div>
<Circle size={12} />
</div>
<input type="radio" checked={props.checked} />
<span>
<span>{props.children}</span>
{props.description && (
<RadioDescription selected={props.checked}>
{props.description}
</RadioDescription>
)}
</span>
</RadioBase>
);
}

View file

@ -1,10 +1,10 @@
import styled, { css } from "styled-components";
export interface TextAreaProps {
code?: boolean;
padding?: number;
lineHeight?: number;
hideBorder?: boolean;
code?: boolean;
padding?: number;
lineHeight?: number;
hideBorder?: boolean;
}
export const TEXT_AREA_BORDER_WIDTH = 2;
@ -12,46 +12,46 @@ export const DEFAULT_TEXT_AREA_PADDING = 16;
export const DEFAULT_LINE_HEIGHT = 20;
export default styled.textarea<TextAreaProps>`
width: 100%;
resize: none;
display: block;
color: var(--foreground);
background: var(--secondary-background);
padding: ${(props) => props.padding ?? DEFAULT_TEXT_AREA_PADDING}px;
line-height: ${(props) => props.lineHeight ?? DEFAULT_LINE_HEIGHT}px;
width: 100%;
resize: none;
display: block;
color: var(--foreground);
background: var(--secondary-background);
padding: ${(props) => props.padding ?? DEFAULT_TEXT_AREA_PADDING}px;
line-height: ${(props) => props.lineHeight ?? DEFAULT_LINE_HEIGHT}px;
${(props) =>
props.hideBorder &&
css`
border: none;
`}
${(props) =>
props.hideBorder &&
css`
border: none;
`}
${(props) =>
!props.hideBorder &&
css`
border-radius: 4px;
transition: border-color 0.2s ease-in-out;
border: ${TEXT_AREA_BORDER_WIDTH}px solid transparent;
`}
${(props) =>
!props.hideBorder &&
css`
border-radius: 4px;
transition: border-color 0.2s ease-in-out;
border: ${TEXT_AREA_BORDER_WIDTH}px solid transparent;
`}
&:focus {
outline: none;
outline: none;
${(props) =>
!props.hideBorder &&
css`
border: ${TEXT_AREA_BORDER_WIDTH}px solid var(--accent);
`}
}
${(props) =>
!props.hideBorder &&
css`
border: ${TEXT_AREA_BORDER_WIDTH}px solid var(--accent);
`}
}
${(props) =>
props.code
? css`
font-family: var(--monoscape-font-font), monospace;
`
: css`
font-family: inherit;
`}
${(props) =>
props.code
? css`
font-family: var(--monoscape-font-font), monospace;
`
: css`
font-family: inherit;
`}
font-variant-ligatures: var(--ligatures);
font-variant-ligatures: var(--ligatures);
`;

View file

@ -4,66 +4,66 @@ import styled, { css } from "styled-components";
import { Children } from "../../types/Preact";
interface Props {
warning?: boolean;
error?: boolean;
warning?: boolean;
error?: boolean;
}
export const Separator = styled.div<Props>`
height: 1px;
width: calc(100% - 10px);
background: var(--secondary-header);
margin: 18px auto;
height: 1px;
width: calc(100% - 10px);
background: var(--secondary-header);
margin: 18px auto;
`;
export const TipBase = styled.div<Props>`
display: flex;
padding: 12px;
overflow: hidden;
align-items: center;
display: flex;
padding: 12px;
overflow: hidden;
align-items: center;
font-size: 14px;
border-radius: 7px;
background: var(--primary-header);
border: 2px solid var(--secondary-header);
font-size: 14px;
border-radius: 7px;
background: var(--primary-header);
border: 2px solid var(--secondary-header);
a {
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
a {
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
svg {
flex-shrink: 0;
margin-inline-end: 10px;
}
svg {
flex-shrink: 0;
margin-inline-end: 10px;
}
${(props) =>
props.warning &&
css`
color: var(--warning);
border: 2px solid var(--warning);
background: var(--secondary-header);
`}
${(props) =>
props.warning &&
css`
color: var(--warning);
border: 2px solid var(--warning);
background: var(--secondary-header);
`}
${(props) =>
props.error &&
css`
color: var(--error);
border: 2px solid var(--error);
background: var(--secondary-header);
`}
${(props) =>
props.error &&
css`
color: var(--error);
border: 2px solid var(--error);
background: var(--secondary-header);
`}
`;
export default function Tip(props: Props & { children: Children }) {
const { children, ...tipProps } = props;
return (
<>
<Separator />
<TipBase {...tipProps}>
<InfoCircle size={20} />
<span>{props.children}</span>
</TipBase>
</>
);
const { children, ...tipProps } = props;
return (
<>
<Separator />
<TipBase {...tipProps}>
<InfoCircle size={20} />
<span>{props.children}</span>
</TipBase>
</>
);
}

View file

@ -16,202 +16,202 @@ dayjs.extend(format);
dayjs.extend(update);
export enum Language {
ENGLISH = "en",
ENGLISH = "en",
ARABIC = "ar",
AZERBAIJANI = "az",
CZECH = "cs",
GERMAN = "de",
SPANISH = "es",
FINNISH = "fi",
FRENCH = "fr",
HINDI = "hi",
CROATIAN = "hr",
HUNGARIAN = "hu",
INDONESIAN = "id",
LITHUANIAN = "lt",
MACEDONIAN = "mk",
DUTCH = "nl",
POLISH = "pl",
PORTUGUESE_BRAZIL = "pt_BR",
ROMANIAN = "ro",
RUSSIAN = "ru",
SERBIAN = "sr",
SWEDISH = "sv",
TURKISH = "tr",
UKRANIAN = "uk",
CHINESE_SIMPLIFIED = "zh_Hans",
ARABIC = "ar",
AZERBAIJANI = "az",
CZECH = "cs",
GERMAN = "de",
SPANISH = "es",
FINNISH = "fi",
FRENCH = "fr",
HINDI = "hi",
CROATIAN = "hr",
HUNGARIAN = "hu",
INDONESIAN = "id",
LITHUANIAN = "lt",
MACEDONIAN = "mk",
DUTCH = "nl",
POLISH = "pl",
PORTUGUESE_BRAZIL = "pt_BR",
ROMANIAN = "ro",
RUSSIAN = "ru",
SERBIAN = "sr",
SWEDISH = "sv",
TURKISH = "tr",
UKRANIAN = "uk",
CHINESE_SIMPLIFIED = "zh_Hans",
OWO = "owo",
PIRATE = "pr",
BOTTOM = "bottom",
PIGLATIN = "piglatin",
OWO = "owo",
PIRATE = "pr",
BOTTOM = "bottom",
PIGLATIN = "piglatin",
}
export interface LanguageEntry {
display: string;
emoji: string;
i18n: string;
dayjs?: string;
rtl?: boolean;
alt?: boolean;
display: string;
emoji: string;
i18n: string;
dayjs?: string;
rtl?: boolean;
alt?: boolean;
}
export const Languages: { [key in Language]: LanguageEntry } = {
en: {
display: "English (Traditional)",
emoji: "🇬🇧",
i18n: "en",
dayjs: "en-gb",
},
en: {
display: "English (Traditional)",
emoji: "🇬🇧",
i18n: "en",
dayjs: "en-gb",
},
ar: { display: "عربي", emoji: "🇸🇦", i18n: "ar", rtl: true },
az: { display: "Azərbaycan dili", emoji: "🇦🇿", i18n: "az" },
cs: { display: "Čeština", emoji: "🇨🇿", i18n: "cs" },
de: { display: "Deutsch", emoji: "🇩🇪", i18n: "de" },
es: { display: "Español", emoji: "🇪🇸", i18n: "es" },
fi: { display: "suomi", emoji: "🇫🇮", i18n: "fi" },
fr: { display: "Français", emoji: "🇫🇷", i18n: "fr" },
hi: { display: "हिन्दी", emoji: "🇮🇳", i18n: "hi" },
hr: { display: "Hrvatski", emoji: "🇭🇷", i18n: "hr" },
hu: { display: "magyar", emoji: "🇭🇺", i18n: "hu" },
id: { display: "bahasa Indonesia", emoji: "🇮🇩", i18n: "id" },
lt: { display: "Lietuvių", emoji: "🇱🇹", i18n: "lt" },
mk: { display: "Македонски", emoji: "🇲🇰", i18n: "mk" },
nl: { display: "Nederlands", emoji: "🇳🇱", i18n: "nl" },
pl: { display: "Polski", emoji: "🇵🇱", i18n: "pl" },
pt_BR: {
display: "Português (do Brasil)",
emoji: "🇧🇷",
i18n: "pt_BR",
dayjs: "pt-br",
},
ro: { display: "Română", emoji: "🇷🇴", i18n: "ro" },
ru: { display: "Русский", emoji: "🇷🇺", i18n: "ru" },
sr: { display: "Српски", emoji: "🇷🇸", i18n: "sr" },
sv: { display: "Svenska", emoji: "🇸🇪", i18n: "sv" },
tr: { display: "Türkçe", emoji: "🇹🇷", i18n: "tr" },
uk: { display: "Українська", emoji: "🇺🇦", i18n: "uk" },
zh_Hans: {
display: "中文 (简体)",
emoji: "🇨🇳",
i18n: "zh_Hans",
dayjs: "zh",
},
ar: { display: "عربي", emoji: "🇸🇦", i18n: "ar", rtl: true },
az: { display: "Azərbaycan dili", emoji: "🇦🇿", i18n: "az" },
cs: { display: "Čeština", emoji: "🇨🇿", i18n: "cs" },
de: { display: "Deutsch", emoji: "🇩🇪", i18n: "de" },
es: { display: "Español", emoji: "🇪🇸", i18n: "es" },
fi: { display: "suomi", emoji: "🇫🇮", i18n: "fi" },
fr: { display: "Français", emoji: "🇫🇷", i18n: "fr" },
hi: { display: "हिन्दी", emoji: "🇮🇳", i18n: "hi" },
hr: { display: "Hrvatski", emoji: "🇭🇷", i18n: "hr" },
hu: { display: "magyar", emoji: "🇭🇺", i18n: "hu" },
id: { display: "bahasa Indonesia", emoji: "🇮🇩", i18n: "id" },
lt: { display: "Lietuvių", emoji: "🇱🇹", i18n: "lt" },
mk: { display: "Македонски", emoji: "🇲🇰", i18n: "mk" },
nl: { display: "Nederlands", emoji: "🇳🇱", i18n: "nl" },
pl: { display: "Polski", emoji: "🇵🇱", i18n: "pl" },
pt_BR: {
display: "Português (do Brasil)",
emoji: "🇧🇷",
i18n: "pt_BR",
dayjs: "pt-br",
},
ro: { display: "Română", emoji: "🇷🇴", i18n: "ro" },
ru: { display: "Русский", emoji: "🇷🇺", i18n: "ru" },
sr: { display: "Српски", emoji: "🇷🇸", i18n: "sr" },
sv: { display: "Svenska", emoji: "🇸🇪", i18n: "sv" },
tr: { display: "Türkçe", emoji: "🇹🇷", i18n: "tr" },
uk: { display: "Українська", emoji: "🇺🇦", i18n: "uk" },
zh_Hans: {
display: "中文 (简体)",
emoji: "🇨🇳",
i18n: "zh_Hans",
dayjs: "zh",
},
owo: {
display: "OwO",
emoji: "🐱",
i18n: "owo",
dayjs: "en-gb",
alt: true,
},
pr: {
display: "Pirate",
emoji: "🏴‍☠️",
i18n: "pr",
dayjs: "en-gb",
alt: true,
},
bottom: {
display: "Bottom",
emoji: "🥺",
i18n: "bottom",
dayjs: "en-gb",
alt: true,
},
piglatin: {
display: "Pig Latin",
emoji: "🐖",
i18n: "piglatin",
dayjs: "en-gb",
alt: true,
},
owo: {
display: "OwO",
emoji: "🐱",
i18n: "owo",
dayjs: "en-gb",
alt: true,
},
pr: {
display: "Pirate",
emoji: "🏴‍☠️",
i18n: "pr",
dayjs: "en-gb",
alt: true,
},
bottom: {
display: "Bottom",
emoji: "🥺",
i18n: "bottom",
dayjs: "en-gb",
alt: true,
},
piglatin: {
display: "Pig Latin",
emoji: "🐖",
i18n: "piglatin",
dayjs: "en-gb",
alt: true,
},
};
interface Props {
children: JSX.Element | JSX.Element[];
locale: Language;
children: JSX.Element | JSX.Element[];
locale: Language;
}
function Locale({ children, locale }: Props) {
// TODO: create and use LanguageDefinition type here
const [defns, setDefinition] =
useState<Record<string, unknown>>(definition);
const lang = Languages[locale];
// TODO: create and use LanguageDefinition type here
const [defns, setDefinition] =
useState<Record<string, unknown>>(definition);
const lang = Languages[locale];
// TODO: clean this up and use the built in Intl API
function transformLanguage(source: { [key: string]: any }) {
const obj = defaultsDeep(source, definition);
// TODO: clean this up and use the built in Intl API
function transformLanguage(source: { [key: string]: any }) {
const obj = defaultsDeep(source, definition);
const dayjs = obj.dayjs;
const defaults = dayjs.defaults;
const dayjs = obj.dayjs;
const defaults = dayjs.defaults;
const twelvehour = defaults?.twelvehour === "yes" || true;
const separator: "/" | "-" | "." = defaults?.date_separator ?? "/";
const date: "traditional" | "simplified" | "ISO8601" =
defaults?.date_format ?? "traditional";
const twelvehour = defaults?.twelvehour === "yes" || true;
const separator: "/" | "-" | "." = defaults?.date_separator ?? "/";
const date: "traditional" | "simplified" | "ISO8601" =
defaults?.date_format ?? "traditional";
const DATE_FORMATS = {
traditional: `DD${separator}MM${separator}YYYY`,
simplified: `MM${separator}DD${separator}YYYY`,
ISO8601: "YYYY-MM-DD",
};
const DATE_FORMATS = {
traditional: `DD${separator}MM${separator}YYYY`,
simplified: `MM${separator}DD${separator}YYYY`,
ISO8601: "YYYY-MM-DD",
};
dayjs["sameElse"] = DATE_FORMATS[date];
Object.keys(dayjs)
.filter((k) => k !== "defaults")
.forEach(
(k) =>
(dayjs[k] = dayjs[k].replace(
/{{time}}/g,
twelvehour ? "LT" : "HH:mm",
)),
);
dayjs["sameElse"] = DATE_FORMATS[date];
Object.keys(dayjs)
.filter((k) => k !== "defaults")
.forEach(
(k) =>
(dayjs[k] = dayjs[k].replace(
/{{time}}/g,
twelvehour ? "LT" : "HH:mm",
)),
);
return obj;
}
return obj;
}
useEffect(() => {
if (locale === "en") {
const defn = transformLanguage(definition);
setDefinition(defn);
dayjs.locale("en");
dayjs.updateLocale("en", { calendar: defn.dayjs });
return;
}
useEffect(() => {
if (locale === "en") {
const defn = transformLanguage(definition);
setDefinition(defn);
dayjs.locale("en");
dayjs.updateLocale("en", { calendar: defn.dayjs });
return;
}
import(`../../external/lang/${lang.i18n}.json`).then(
async (lang_file) => {
const defn = transformLanguage(lang_file.default);
const target = lang.dayjs ?? lang.i18n;
const dayjs_locale = await import(
`../../node_modules/dayjs/esm/locale/${target}.js`
);
import(`../../external/lang/${lang.i18n}.json`).then(
async (lang_file) => {
const defn = transformLanguage(lang_file.default);
const target = lang.dayjs ?? lang.i18n;
const dayjs_locale = await import(
`../../node_modules/dayjs/esm/locale/${target}.js`
);
if (defn.dayjs) {
dayjs.updateLocale(target, { calendar: defn.dayjs });
}
if (defn.dayjs) {
dayjs.updateLocale(target, { calendar: defn.dayjs });
}
dayjs.locale(dayjs_locale.default);
setDefinition(defn);
},
);
}, [locale, lang]);
dayjs.locale(dayjs_locale.default);
setDefinition(defn);
},
);
}, [locale, lang]);
useEffect(() => {
document.body.style.direction = lang.rtl ? "rtl" : "";
}, [lang.rtl]);
useEffect(() => {
document.body.style.direction = lang.rtl ? "rtl" : "";
}, [lang.rtl]);
return <IntlProvider definition={defns}>{children}</IntlProvider>;
return <IntlProvider definition={defns}>{children}</IntlProvider>;
}
export default connectState<Omit<Props, "locale">>(
Locale,
(state) => {
return {
locale: state.locale,
};
},
true,
Locale,
(state) => {
return {
locale: state.locale,
};
},
true,
);

View file

@ -13,9 +13,9 @@ import { useMemo } from "preact/hooks";
import { connectState } from "../redux/connector";
import {
DEFAULT_SOUNDS,
Settings,
SoundOptions,
DEFAULT_SOUNDS,
Settings,
SoundOptions,
} from "../redux/reducers/settings";
import { playSound, Sounds } from "../assets/sounds/Audio";
@ -25,37 +25,37 @@ export const SettingsContext = createContext<Settings>({});
export const SoundContext = createContext<(sound: Sounds) => void>(null!);
interface Props {
children?: Children;
settings: Settings;
children?: Children;
settings: Settings;
}
function SettingsProvider({ settings, children }: Props) {
const play = useMemo(() => {
const enabled: SoundOptions = defaultsDeep(
settings.notification?.sounds ?? {},
DEFAULT_SOUNDS,
);
return (sound: Sounds) => {
if (enabled[sound]) {
playSound(sound);
}
};
}, [settings.notification]);
const play = useMemo(() => {
const enabled: SoundOptions = defaultsDeep(
settings.notification?.sounds ?? {},
DEFAULT_SOUNDS,
);
return (sound: Sounds) => {
if (enabled[sound]) {
playSound(sound);
}
};
}, [settings.notification]);
return (
<SettingsContext.Provider value={settings}>
<SoundContext.Provider value={play}>
{children}
</SoundContext.Provider>
</SettingsContext.Provider>
);
return (
<SettingsContext.Provider value={settings}>
<SoundContext.Provider value={play}>
{children}
</SoundContext.Provider>
</SettingsContext.Provider>
);
}
export default connectState<Omit<Props, "settings">>(
SettingsProvider,
(state) => {
return {
settings: state.settings,
};
},
SettingsProvider,
(state) => {
return {
settings: state.settings,
};
},
);

View file

@ -11,209 +11,209 @@ import { connectState } from "../redux/connector";
import { Children } from "../types/Preact";
export type Variables =
| "accent"
| "background"
| "foreground"
| "block"
| "message-box"
| "mention"
| "success"
| "warning"
| "error"
| "hover"
| "scrollbar-thumb"
| "scrollbar-track"
| "primary-background"
| "primary-header"
| "secondary-background"
| "secondary-foreground"
| "secondary-header"
| "tertiary-background"
| "tertiary-foreground"
| "status-online"
| "status-away"
| "status-busy"
| "status-streaming"
| "status-invisible";
| "accent"
| "background"
| "foreground"
| "block"
| "message-box"
| "mention"
| "success"
| "warning"
| "error"
| "hover"
| "scrollbar-thumb"
| "scrollbar-track"
| "primary-background"
| "primary-header"
| "secondary-background"
| "secondary-foreground"
| "secondary-header"
| "tertiary-background"
| "tertiary-foreground"
| "status-online"
| "status-away"
| "status-busy"
| "status-streaming"
| "status-invisible";
// While this isn't used, it'd be good to keep this up to date as a reference or for future use
export type HiddenVariables =
| "font"
| "ligatures"
| "app-height"
| "sidebar-active"
| "monospace-font";
| "font"
| "ligatures"
| "app-height"
| "sidebar-active"
| "monospace-font";
export type Fonts =
| "Open Sans"
| "Inter"
| "Atkinson Hyperlegible"
| "Roboto"
| "Noto Sans"
| "Lato"
| "Bree Serif"
| "Montserrat"
| "Poppins"
| "Raleway"
| "Ubuntu"
| "Comic Neue";
| "Open Sans"
| "Inter"
| "Atkinson Hyperlegible"
| "Roboto"
| "Noto Sans"
| "Lato"
| "Bree Serif"
| "Montserrat"
| "Poppins"
| "Raleway"
| "Ubuntu"
| "Comic Neue";
export type MonoscapeFonts =
| "Fira Code"
| "Roboto Mono"
| "Source Code Pro"
| "Space Mono"
| "Ubuntu Mono";
| "Fira Code"
| "Roboto Mono"
| "Source Code Pro"
| "Space Mono"
| "Ubuntu Mono";
export type Theme = {
[variable in Variables]: string;
[variable in Variables]: string;
} & {
light?: boolean;
font?: Fonts;
css?: string;
monoscapeFont?: MonoscapeFonts;
light?: boolean;
font?: Fonts;
css?: string;
monoscapeFont?: MonoscapeFonts;
};
export interface ThemeOptions {
preset?: string;
ligatures?: boolean;
custom?: Partial<Theme>;
preset?: string;
ligatures?: boolean;
custom?: Partial<Theme>;
}
// import aaa from "@fontsource/open-sans/300.css?raw";
// console.info(aaa);
export const FONTS: Record<Fonts, { name: string; load: () => void }> = {
"Open Sans": {
name: "Open Sans",
load: async () => {
await import("@fontsource/open-sans/300.css");
await import("@fontsource/open-sans/400.css");
await import("@fontsource/open-sans/600.css");
await import("@fontsource/open-sans/700.css");
await import("@fontsource/open-sans/400-italic.css");
},
},
Inter: {
name: "Inter",
load: async () => {
await import("@fontsource/inter/300.css");
await import("@fontsource/inter/400.css");
await import("@fontsource/inter/600.css");
await import("@fontsource/inter/700.css");
},
},
"Atkinson Hyperlegible": {
name: "Atkinson Hyperlegible",
load: async () => {
await import("@fontsource/atkinson-hyperlegible/400.css");
await import("@fontsource/atkinson-hyperlegible/700.css");
await import("@fontsource/atkinson-hyperlegible/400-italic.css");
},
},
Roboto: {
name: "Roboto",
load: async () => {
await import("@fontsource/roboto/400.css");
await import("@fontsource/roboto/700.css");
await import("@fontsource/roboto/400-italic.css");
},
},
"Noto Sans": {
name: "Noto Sans",
load: async () => {
await import("@fontsource/noto-sans/400.css");
await import("@fontsource/noto-sans/700.css");
await import("@fontsource/noto-sans/400-italic.css");
},
},
"Bree Serif": {
name: "Bree Serif",
load: () => import("@fontsource/bree-serif/400.css"),
},
Lato: {
name: "Lato",
load: async () => {
await import("@fontsource/lato/300.css");
await import("@fontsource/lato/400.css");
await import("@fontsource/lato/700.css");
await import("@fontsource/lato/400-italic.css");
},
},
Montserrat: {
name: "Montserrat",
load: async () => {
await import("@fontsource/montserrat/300.css");
await import("@fontsource/montserrat/400.css");
await import("@fontsource/montserrat/600.css");
await import("@fontsource/montserrat/700.css");
await import("@fontsource/montserrat/400-italic.css");
},
},
Poppins: {
name: "Poppins",
load: async () => {
await import("@fontsource/poppins/300.css");
await import("@fontsource/poppins/400.css");
await import("@fontsource/poppins/600.css");
await import("@fontsource/poppins/700.css");
await import("@fontsource/poppins/400-italic.css");
},
},
Raleway: {
name: "Raleway",
load: async () => {
await import("@fontsource/raleway/300.css");
await import("@fontsource/raleway/400.css");
await import("@fontsource/raleway/600.css");
await import("@fontsource/raleway/700.css");
await import("@fontsource/raleway/400-italic.css");
},
},
Ubuntu: {
name: "Ubuntu",
load: async () => {
await import("@fontsource/ubuntu/300.css");
await import("@fontsource/ubuntu/400.css");
await import("@fontsource/ubuntu/500.css");
await import("@fontsource/ubuntu/700.css");
await import("@fontsource/ubuntu/400-italic.css");
},
},
"Comic Neue": {
name: "Comic Neue",
load: async () => {
await import("@fontsource/comic-neue/300.css");
await import("@fontsource/comic-neue/400.css");
await import("@fontsource/comic-neue/700.css");
await import("@fontsource/comic-neue/400-italic.css");
},
},
"Open Sans": {
name: "Open Sans",
load: async () => {
await import("@fontsource/open-sans/300.css");
await import("@fontsource/open-sans/400.css");
await import("@fontsource/open-sans/600.css");
await import("@fontsource/open-sans/700.css");
await import("@fontsource/open-sans/400-italic.css");
},
},
Inter: {
name: "Inter",
load: async () => {
await import("@fontsource/inter/300.css");
await import("@fontsource/inter/400.css");
await import("@fontsource/inter/600.css");
await import("@fontsource/inter/700.css");
},
},
"Atkinson Hyperlegible": {
name: "Atkinson Hyperlegible",
load: async () => {
await import("@fontsource/atkinson-hyperlegible/400.css");
await import("@fontsource/atkinson-hyperlegible/700.css");
await import("@fontsource/atkinson-hyperlegible/400-italic.css");
},
},
Roboto: {
name: "Roboto",
load: async () => {
await import("@fontsource/roboto/400.css");
await import("@fontsource/roboto/700.css");
await import("@fontsource/roboto/400-italic.css");
},
},
"Noto Sans": {
name: "Noto Sans",
load: async () => {
await import("@fontsource/noto-sans/400.css");
await import("@fontsource/noto-sans/700.css");
await import("@fontsource/noto-sans/400-italic.css");
},
},
"Bree Serif": {
name: "Bree Serif",
load: () => import("@fontsource/bree-serif/400.css"),
},
Lato: {
name: "Lato",
load: async () => {
await import("@fontsource/lato/300.css");
await import("@fontsource/lato/400.css");
await import("@fontsource/lato/700.css");
await import("@fontsource/lato/400-italic.css");
},
},
Montserrat: {
name: "Montserrat",
load: async () => {
await import("@fontsource/montserrat/300.css");
await import("@fontsource/montserrat/400.css");
await import("@fontsource/montserrat/600.css");
await import("@fontsource/montserrat/700.css");
await import("@fontsource/montserrat/400-italic.css");
},
},
Poppins: {
name: "Poppins",
load: async () => {
await import("@fontsource/poppins/300.css");
await import("@fontsource/poppins/400.css");
await import("@fontsource/poppins/600.css");
await import("@fontsource/poppins/700.css");
await import("@fontsource/poppins/400-italic.css");
},
},
Raleway: {
name: "Raleway",
load: async () => {
await import("@fontsource/raleway/300.css");
await import("@fontsource/raleway/400.css");
await import("@fontsource/raleway/600.css");
await import("@fontsource/raleway/700.css");
await import("@fontsource/raleway/400-italic.css");
},
},
Ubuntu: {
name: "Ubuntu",
load: async () => {
await import("@fontsource/ubuntu/300.css");
await import("@fontsource/ubuntu/400.css");
await import("@fontsource/ubuntu/500.css");
await import("@fontsource/ubuntu/700.css");
await import("@fontsource/ubuntu/400-italic.css");
},
},
"Comic Neue": {
name: "Comic Neue",
load: async () => {
await import("@fontsource/comic-neue/300.css");
await import("@fontsource/comic-neue/400.css");
await import("@fontsource/comic-neue/700.css");
await import("@fontsource/comic-neue/400-italic.css");
},
},
};
export const MONOSCAPE_FONTS: Record<
MonoscapeFonts,
{ name: string; load: () => void }
MonoscapeFonts,
{ name: string; load: () => void }
> = {
"Fira Code": {
name: "Fira Code",
load: () => import("@fontsource/fira-code/400.css"),
},
"Roboto Mono": {
name: "Roboto Mono",
load: () => import("@fontsource/roboto-mono/400.css"),
},
"Source Code Pro": {
name: "Source Code Pro",
load: () => import("@fontsource/source-code-pro/400.css"),
},
"Space Mono": {
name: "Space Mono",
load: () => import("@fontsource/space-mono/400.css"),
},
"Ubuntu Mono": {
name: "Ubuntu Mono",
load: () => import("@fontsource/ubuntu-mono/400.css"),
},
"Fira Code": {
name: "Fira Code",
load: () => import("@fontsource/fira-code/400.css"),
},
"Roboto Mono": {
name: "Roboto Mono",
load: () => import("@fontsource/roboto-mono/400.css"),
},
"Source Code Pro": {
name: "Source Code Pro",
load: () => import("@fontsource/source-code-pro/400.css"),
},
"Space Mono": {
name: "Space Mono",
load: () => import("@fontsource/space-mono/400.css"),
},
"Ubuntu Mono": {
name: "Ubuntu Mono",
load: () => import("@fontsource/ubuntu-mono/400.css"),
},
};
export const FONT_KEYS = Object.keys(FONTS).sort();
@ -224,70 +224,70 @@ export const DEFAULT_MONO_FONT = "Fira Code";
// Generated from https://gitlab.insrt.uk/revolt/community/themes
export const PRESETS: Record<string, Theme> = {
light: {
light: true,
accent: "#FD6671",
background: "#F6F6F6",
foreground: "#101010",
block: "#414141",
"message-box": "#F1F1F1",
mention: "rgba(251, 255, 0, 0.40)",
success: "#65E572",
warning: "#FAA352",
error: "#F06464",
hover: "rgba(0, 0, 0, 0.2)",
"scrollbar-thumb": "#CA525A",
"scrollbar-track": "transparent",
"primary-background": "#FFFFFF",
"primary-header": "#F1F1F1",
"secondary-background": "#F1F1F1",
"secondary-foreground": "#888888",
"secondary-header": "#F1F1F1",
"tertiary-background": "#4D4D4D",
"tertiary-foreground": "#646464",
"status-online": "#3ABF7E",
"status-away": "#F39F00",
"status-busy": "#F84848",
"status-streaming": "#977EFF",
"status-invisible": "#A5A5A5",
},
dark: {
light: false,
accent: "#FD6671",
background: "#191919",
foreground: "#F6F6F6",
block: "#2D2D2D",
"message-box": "#363636",
mention: "rgba(251, 255, 0, 0.06)",
success: "#65E572",
warning: "#FAA352",
error: "#F06464",
hover: "rgba(0, 0, 0, 0.1)",
"scrollbar-thumb": "#CA525A",
"scrollbar-track": "transparent",
"primary-background": "#242424",
"primary-header": "#363636",
"secondary-background": "#1E1E1E",
"secondary-foreground": "#C8C8C8",
"secondary-header": "#2D2D2D",
"tertiary-background": "#4D4D4D",
"tertiary-foreground": "#848484",
"status-online": "#3ABF7E",
"status-away": "#F39F00",
"status-busy": "#F84848",
"status-streaming": "#977EFF",
"status-invisible": "#A5A5A5",
},
light: {
light: true,
accent: "#FD6671",
background: "#F6F6F6",
foreground: "#101010",
block: "#414141",
"message-box": "#F1F1F1",
mention: "rgba(251, 255, 0, 0.40)",
success: "#65E572",
warning: "#FAA352",
error: "#F06464",
hover: "rgba(0, 0, 0, 0.2)",
"scrollbar-thumb": "#CA525A",
"scrollbar-track": "transparent",
"primary-background": "#FFFFFF",
"primary-header": "#F1F1F1",
"secondary-background": "#F1F1F1",
"secondary-foreground": "#888888",
"secondary-header": "#F1F1F1",
"tertiary-background": "#4D4D4D",
"tertiary-foreground": "#646464",
"status-online": "#3ABF7E",
"status-away": "#F39F00",
"status-busy": "#F84848",
"status-streaming": "#977EFF",
"status-invisible": "#A5A5A5",
},
dark: {
light: false,
accent: "#FD6671",
background: "#191919",
foreground: "#F6F6F6",
block: "#2D2D2D",
"message-box": "#363636",
mention: "rgba(251, 255, 0, 0.06)",
success: "#65E572",
warning: "#FAA352",
error: "#F06464",
hover: "rgba(0, 0, 0, 0.1)",
"scrollbar-thumb": "#CA525A",
"scrollbar-track": "transparent",
"primary-background": "#242424",
"primary-header": "#363636",
"secondary-background": "#1E1E1E",
"secondary-foreground": "#C8C8C8",
"secondary-header": "#2D2D2D",
"tertiary-background": "#4D4D4D",
"tertiary-foreground": "#848484",
"status-online": "#3ABF7E",
"status-away": "#F39F00",
"status-busy": "#F84848",
"status-streaming": "#977EFF",
"status-invisible": "#A5A5A5",
},
};
const keys = Object.keys(PRESETS.dark);
const GlobalTheme = createGlobalStyle<{ theme: Theme }>`
:root {
${(props) =>
(Object.keys(props.theme) as Variables[]).map((key) => {
if (!keys.includes(key)) return;
return `--${key}: ${props.theme[key]};`;
})}
(Object.keys(props.theme) as Variables[]).map((key) => {
if (!keys.includes(key)) return;
return `--${key}: ${props.theme[key]};`;
})}
}
`;
@ -295,66 +295,66 @@ const GlobalTheme = createGlobalStyle<{ theme: Theme }>`
export const ThemeContext = createContext<Theme>(PRESETS["dark"]);
interface Props {
children: Children;
options?: ThemeOptions;
children: Children;
options?: ThemeOptions;
}
function Theme({ children, options }: Props) {
const theme: Theme = {
...PRESETS["dark"],
...PRESETS[options?.preset ?? ""],
...options?.custom,
};
const theme: Theme = {
...PRESETS["dark"],
...PRESETS[options?.preset ?? ""],
...options?.custom,
};
const root = document.documentElement.style;
useEffect(() => {
const font = theme.font ?? DEFAULT_FONT;
root.setProperty("--font", `"${font}"`);
FONTS[font].load();
}, [theme.font]);
const root = document.documentElement.style;
useEffect(() => {
const font = theme.font ?? DEFAULT_FONT;
root.setProperty("--font", `"${font}"`);
FONTS[font].load();
}, [theme.font]);
useEffect(() => {
const font = theme.monoscapeFont ?? DEFAULT_MONO_FONT;
root.setProperty("--monoscape-font", `"${font}"`);
MONOSCAPE_FONTS[font].load();
}, [theme.monoscapeFont]);
useEffect(() => {
const font = theme.monoscapeFont ?? DEFAULT_MONO_FONT;
root.setProperty("--monoscape-font", `"${font}"`);
MONOSCAPE_FONTS[font].load();
}, [theme.monoscapeFont]);
useEffect(() => {
root.setProperty("--ligatures", options?.ligatures ? "normal" : "none");
}, [options?.ligatures]);
useEffect(() => {
root.setProperty("--ligatures", options?.ligatures ? "normal" : "none");
}, [options?.ligatures]);
useEffect(() => {
const resize = () =>
root.setProperty("--app-height", `${window.innerHeight}px`);
resize();
useEffect(() => {
const resize = () =>
root.setProperty("--app-height", `${window.innerHeight}px`);
resize();
window.addEventListener("resize", resize);
return () => window.removeEventListener("resize", resize);
}, []);
window.addEventListener("resize", resize);
return () => window.removeEventListener("resize", resize);
}, []);
return (
<ThemeContext.Provider value={theme}>
<Helmet>
<meta
name="theme-color"
content={
isTouchscreenDevice
? theme["primary-header"]
: theme["background"]
}
/>
</Helmet>
<GlobalTheme theme={theme} />
{theme.css && (
<style dangerouslySetInnerHTML={{ __html: theme.css }} />
)}
{children}
</ThemeContext.Provider>
);
return (
<ThemeContext.Provider value={theme}>
<Helmet>
<meta
name="theme-color"
content={
isTouchscreenDevice
? theme["primary-header"]
: theme["background"]
}
/>
</Helmet>
<GlobalTheme theme={theme} />
{theme.css && (
<style dangerouslySetInnerHTML={{ __html: theme.css }} />
)}
{children}
</ThemeContext.Provider>
);
}
export default connectState<{ children: Children }>(Theme, (state) => {
return {
options: state.settings.theme,
};
return {
options: state.settings.theme,
};
});

View file

@ -10,29 +10,29 @@ import { AppContext } from "./revoltjs/RevoltClient";
import { useForceUpdate } from "./revoltjs/hooks";
export enum VoiceStatus {
LOADING = 0,
UNAVAILABLE,
ERRORED,
READY = 3,
CONNECTING = 4,
AUTHENTICATING,
RTC_CONNECTING,
CONNECTED,
// RECONNECTING
LOADING = 0,
UNAVAILABLE,
ERRORED,
READY = 3,
CONNECTING = 4,
AUTHENTICATING,
RTC_CONNECTING,
CONNECTED,
// RECONNECTING
}
export interface VoiceOperations {
connect: (channelId: string) => Promise<void>;
disconnect: () => void;
isProducing: (type: ProduceType) => boolean;
startProducing: (type: ProduceType) => Promise<void>;
stopProducing: (type: ProduceType) => Promise<void> | undefined;
connect: (channelId: string) => Promise<void>;
disconnect: () => void;
isProducing: (type: ProduceType) => boolean;
startProducing: (type: ProduceType) => Promise<void>;
stopProducing: (type: ProduceType) => Promise<void> | undefined;
}
export interface VoiceState {
roomId?: string;
status: VoiceStatus;
participants?: Readonly<Map<string, VoiceUser>>;
roomId?: string;
status: VoiceStatus;
participants?: Readonly<Map<string, VoiceUser>>;
}
// They should be present from first render. - insert's words
@ -40,168 +40,168 @@ export const VoiceContext = createContext<VoiceState>(null!);
export const VoiceOperationsContext = createContext<VoiceOperations>(null!);
type Props = {
children: Children;
children: Children;
};
export default function Voice({ children }: Props) {
const revoltClient = useContext(AppContext);
const [client, setClient] = useState<VoiceClient | undefined>(undefined);
const [state, setState] = useState<VoiceState>({
status: VoiceStatus.LOADING,
participants: new Map(),
});
const revoltClient = useContext(AppContext);
const [client, setClient] = useState<VoiceClient | undefined>(undefined);
const [state, setState] = useState<VoiceState>({
status: VoiceStatus.LOADING,
participants: new Map(),
});
function setStatus(status: VoiceStatus, roomId?: string) {
setState({
status,
roomId: roomId ?? client?.roomId,
participants: client?.participants ?? new Map(),
});
}
function setStatus(status: VoiceStatus, roomId?: string) {
setState({
status,
roomId: roomId ?? client?.roomId,
participants: client?.participants ?? new Map(),
});
}
useEffect(() => {
import("../lib/vortex/VoiceClient")
.then(({ default: VoiceClient }) => {
const client = new VoiceClient();
setClient(client);
useEffect(() => {
import("../lib/vortex/VoiceClient")
.then(({ default: VoiceClient }) => {
const client = new VoiceClient();
setClient(client);
if (!client?.supported()) {
setStatus(VoiceStatus.UNAVAILABLE);
} else {
setStatus(VoiceStatus.READY);
}
})
.catch((err) => {
console.error("Failed to load voice library!", err);
setStatus(VoiceStatus.UNAVAILABLE);
});
}, []);
if (!client?.supported()) {
setStatus(VoiceStatus.UNAVAILABLE);
} else {
setStatus(VoiceStatus.READY);
}
})
.catch((err) => {
console.error("Failed to load voice library!", err);
setStatus(VoiceStatus.UNAVAILABLE);
});
}, []);
const isConnecting = useRef(false);
const operations: VoiceOperations = useMemo(() => {
return {
connect: async (channelId) => {
if (!client?.supported()) throw new Error("RTC is unavailable");
const isConnecting = useRef(false);
const operations: VoiceOperations = useMemo(() => {
return {
connect: async (channelId) => {
if (!client?.supported()) throw new Error("RTC is unavailable");
isConnecting.current = true;
setStatus(VoiceStatus.CONNECTING, channelId);
isConnecting.current = true;
setStatus(VoiceStatus.CONNECTING, channelId);
try {
const call = await revoltClient.channels.joinCall(
channelId,
);
try {
const call = await revoltClient.channels.joinCall(
channelId,
);
if (!isConnecting.current) {
setStatus(VoiceStatus.READY);
return;
}
if (!isConnecting.current) {
setStatus(VoiceStatus.READY);
return;
}
// ! FIXME: use configuration to check if voso is enabled
// await client.connect("wss://voso.revolt.chat/ws");
await client.connect(
"wss://voso.revolt.chat/ws",
channelId,
);
// ! FIXME: use configuration to check if voso is enabled
// await client.connect("wss://voso.revolt.chat/ws");
await client.connect(
"wss://voso.revolt.chat/ws",
channelId,
);
setStatus(VoiceStatus.AUTHENTICATING);
setStatus(VoiceStatus.AUTHENTICATING);
await client.authenticate(call.token);
setStatus(VoiceStatus.RTC_CONNECTING);
await client.authenticate(call.token);
setStatus(VoiceStatus.RTC_CONNECTING);
await client.initializeTransports();
} catch (error) {
console.error(error);
setStatus(VoiceStatus.READY);
return;
}
await client.initializeTransports();
} catch (error) {
console.error(error);
setStatus(VoiceStatus.READY);
return;
}
setStatus(VoiceStatus.CONNECTED);
isConnecting.current = false;
},
disconnect: () => {
if (!client?.supported()) throw new Error("RTC is unavailable");
setStatus(VoiceStatus.CONNECTED);
isConnecting.current = false;
},
disconnect: () => {
if (!client?.supported()) throw new Error("RTC is unavailable");
// if (status <= VoiceStatus.READY) return;
// this will not update in this context
// if (status <= VoiceStatus.READY) return;
// this will not update in this context
isConnecting.current = false;
client.disconnect();
setStatus(VoiceStatus.READY);
},
isProducing: (type: ProduceType) => {
switch (type) {
case "audio":
return client?.audioProducer !== undefined;
}
},
startProducing: async (type: ProduceType) => {
switch (type) {
case "audio": {
if (client?.audioProducer !== undefined)
return console.log("No audio producer."); // ! FIXME: let the user know
if (navigator.mediaDevices === undefined)
return console.log("No media devices."); // ! FIXME: let the user know
const mediaStream =
await navigator.mediaDevices.getUserMedia({
audio: true,
});
isConnecting.current = false;
client.disconnect();
setStatus(VoiceStatus.READY);
},
isProducing: (type: ProduceType) => {
switch (type) {
case "audio":
return client?.audioProducer !== undefined;
}
},
startProducing: async (type: ProduceType) => {
switch (type) {
case "audio": {
if (client?.audioProducer !== undefined)
return console.log("No audio producer."); // ! FIXME: let the user know
if (navigator.mediaDevices === undefined)
return console.log("No media devices."); // ! FIXME: let the user know
const mediaStream =
await navigator.mediaDevices.getUserMedia({
audio: true,
});
await client?.startProduce(
mediaStream.getAudioTracks()[0],
"audio",
);
return;
}
}
},
stopProducing: (type: ProduceType) => {
return client?.stopProduce(type);
},
};
}, [client]);
await client?.startProduce(
mediaStream.getAudioTracks()[0],
"audio",
);
return;
}
}
},
stopProducing: (type: ProduceType) => {
return client?.stopProduce(type);
},
};
}, [client]);
const { forceUpdate } = useForceUpdate();
const playSound = useContext(SoundContext);
const { forceUpdate } = useForceUpdate();
const playSound = useContext(SoundContext);
useEffect(() => {
if (!client?.supported()) return;
useEffect(() => {
if (!client?.supported()) return;
// ! FIXME: message for fatal:
// ! get rid of these force updates
// ! handle it through state or smth
// ! FIXME: message for fatal:
// ! get rid of these force updates
// ! handle it through state or smth
client.on("startProduce", forceUpdate);
client.on("stopProduce", forceUpdate);
client.on("startProduce", forceUpdate);
client.on("stopProduce", forceUpdate);
client.on("userJoined", () => {
playSound("call_join");
forceUpdate();
});
client.on("userLeft", () => {
playSound("call_leave");
forceUpdate();
});
client.on("userStartProduce", forceUpdate);
client.on("userStopProduce", forceUpdate);
client.on("close", forceUpdate);
client.on("userJoined", () => {
playSound("call_join");
forceUpdate();
});
client.on("userLeft", () => {
playSound("call_leave");
forceUpdate();
});
client.on("userStartProduce", forceUpdate);
client.on("userStopProduce", forceUpdate);
client.on("close", forceUpdate);
return () => {
client.removeListener("startProduce", forceUpdate);
client.removeListener("stopProduce", forceUpdate);
return () => {
client.removeListener("startProduce", forceUpdate);
client.removeListener("stopProduce", forceUpdate);
client.removeListener("userJoined", forceUpdate);
client.removeListener("userLeft", forceUpdate);
client.removeListener("userStartProduce", forceUpdate);
client.removeListener("userStopProduce", forceUpdate);
client.removeListener("close", forceUpdate);
};
}, [client, state]);
client.removeListener("userJoined", forceUpdate);
client.removeListener("userLeft", forceUpdate);
client.removeListener("userStartProduce", forceUpdate);
client.removeListener("userStopProduce", forceUpdate);
client.removeListener("close", forceUpdate);
};
}, [client, state]);
return (
<VoiceContext.Provider value={state}>
<VoiceOperationsContext.Provider value={operations}>
{children}
</VoiceOperationsContext.Provider>
</VoiceContext.Provider>
);
return (
<VoiceContext.Provider value={state}>
<VoiceOperationsContext.Provider value={operations}>
{children}
</VoiceOperationsContext.Provider>
</VoiceContext.Provider>
);
}

View file

@ -11,21 +11,21 @@ import Intermediate from "./intermediate/Intermediate";
import Client from "./revoltjs/RevoltClient";
export default function Context({ children }: { children: Children }) {
return (
<Router>
<State>
<Theme>
<Settings>
<Locale>
<Intermediate>
<Client>
<Voice>{children}</Voice>
</Client>
</Intermediate>
</Locale>
</Settings>
</Theme>
</State>
</Router>
);
return (
<Router>
<State>
<Theme>
<Settings>
<Locale>
<Intermediate>
<Client>
<Voice>{children}</Voice>
</Client>
</Intermediate>
</Locale>
</Settings>
</Theme>
</State>
</Router>
);
}

View file

@ -1,11 +1,11 @@
import { Prompt } from "react-router";
import { useHistory } from "react-router-dom";
import {
Attachment,
Channels,
EmbedImage,
Servers,
Users,
Attachment,
Channels,
EmbedImage,
Servers,
Users,
} from "revolt.js/dist/api/objects";
import { createContext } from "preact";
@ -19,161 +19,161 @@ import { Children } from "../../types/Preact";
import Modals from "./Modals";
export type Screen =
| { id: "none" }
| { id: "none" }
// Modals
| { id: "signed_out" }
| { id: "error"; error: string }
| { id: "clipboard"; text: string }
| {
id: "_prompt";
question: Children;
content?: Children;
actions: Action[];
}
| ({ id: "special_prompt" } & (
| { type: "leave_group"; target: Channels.GroupChannel }
| { type: "close_dm"; target: Channels.DirectMessageChannel }
| { type: "leave_server"; target: Servers.Server }
| { type: "delete_server"; target: Servers.Server }
| { type: "delete_channel"; target: Channels.TextChannel }
| { type: "delete_message"; target: Channels.Message }
| {
type: "create_invite";
target: Channels.TextChannel | Channels.GroupChannel;
}
| { type: "kick_member"; target: Servers.Server; user: string }
| { type: "ban_member"; target: Servers.Server; user: string }
| { type: "unfriend_user"; target: Users.User }
| { type: "block_user"; target: Users.User }
| { type: "create_channel"; target: Servers.Server }
))
| ({ id: "special_input" } & (
| {
type:
| "create_group"
| "create_server"
| "set_custom_status"
| "add_friend";
}
| {
type: "create_role";
server: string;
callback: (id: string) => void;
}
))
| {
id: "_input";
question: Children;
field: Children;
defaultValue?: string;
callback: (value: string) => Promise<void>;
}
| {
id: "onboarding";
callback: (
username: string,
loginAfterSuccess?: true,
) => Promise<void>;
}
// Modals
| { id: "signed_out" }
| { id: "error"; error: string }
| { id: "clipboard"; text: string }
| {
id: "_prompt";
question: Children;
content?: Children;
actions: Action[];
}
| ({ id: "special_prompt" } & (
| { type: "leave_group"; target: Channels.GroupChannel }
| { type: "close_dm"; target: Channels.DirectMessageChannel }
| { type: "leave_server"; target: Servers.Server }
| { type: "delete_server"; target: Servers.Server }
| { type: "delete_channel"; target: Channels.TextChannel }
| { type: "delete_message"; target: Channels.Message }
| {
type: "create_invite";
target: Channels.TextChannel | Channels.GroupChannel;
}
| { type: "kick_member"; target: Servers.Server; user: string }
| { type: "ban_member"; target: Servers.Server; user: string }
| { type: "unfriend_user"; target: Users.User }
| { type: "block_user"; target: Users.User }
| { type: "create_channel"; target: Servers.Server }
))
| ({ id: "special_input" } & (
| {
type:
| "create_group"
| "create_server"
| "set_custom_status"
| "add_friend";
}
| {
type: "create_role";
server: string;
callback: (id: string) => void;
}
))
| {
id: "_input";
question: Children;
field: Children;
defaultValue?: string;
callback: (value: string) => Promise<void>;
}
| {
id: "onboarding";
callback: (
username: string,
loginAfterSuccess?: true,
) => Promise<void>;
}
// Pop-overs
| { id: "image_viewer"; attachment?: Attachment; embed?: EmbedImage }
| { id: "modify_account"; field: "username" | "email" | "password" }
| { id: "profile"; user_id: string }
| { id: "channel_info"; channel_id: string }
| { id: "pending_requests"; users: string[] }
| {
id: "user_picker";
omit?: string[];
callback: (users: string[]) => Promise<void>;
};
// Pop-overs
| { id: "image_viewer"; attachment?: Attachment; embed?: EmbedImage }
| { id: "modify_account"; field: "username" | "email" | "password" }
| { id: "profile"; user_id: string }
| { id: "channel_info"; channel_id: string }
| { id: "pending_requests"; users: string[] }
| {
id: "user_picker";
omit?: string[];
callback: (users: string[]) => Promise<void>;
};
export const IntermediateContext = createContext({
screen: { id: "none" } as Screen,
focusTaken: false,
screen: { id: "none" } as Screen,
focusTaken: false,
});
export const IntermediateActionsContext = createContext({
openScreen: (screen: Screen) => {},
writeClipboard: (text: string) => {},
openScreen: (screen: Screen) => {},
writeClipboard: (text: string) => {},
});
interface Props {
children: Children;
children: Children;
}
export default function Intermediate(props: Props) {
const [screen, openScreen] = useState<Screen>({ id: "none" });
const history = useHistory();
const [screen, openScreen] = useState<Screen>({ id: "none" });
const history = useHistory();
const value = {
screen,
focusTaken: screen.id !== "none",
};
const value = {
screen,
focusTaken: screen.id !== "none",
};
const actions = useMemo(() => {
return {
openScreen: (screen: Screen) => openScreen(screen),
writeClipboard: (text: string) => {
if (navigator.clipboard) {
navigator.clipboard.writeText(text);
} else {
actions.openScreen({ id: "clipboard", text });
}
},
};
}, []);
const actions = useMemo(() => {
return {
openScreen: (screen: Screen) => openScreen(screen),
writeClipboard: (text: string) => {
if (navigator.clipboard) {
navigator.clipboard.writeText(text);
} else {
actions.openScreen({ id: "clipboard", text });
}
},
};
}, []);
useEffect(() => {
const openProfile = (user_id: string) =>
openScreen({ id: "profile", user_id });
const navigate = (path: string) => history.push(path);
useEffect(() => {
const openProfile = (user_id: string) =>
openScreen({ id: "profile", user_id });
const navigate = (path: string) => history.push(path);
const subs = [
internalSubscribe("Intermediate", "openProfile", openProfile),
internalSubscribe("Intermediate", "navigate", navigate),
];
const subs = [
internalSubscribe("Intermediate", "openProfile", openProfile),
internalSubscribe("Intermediate", "navigate", navigate),
];
return () => subs.map((unsub) => unsub());
}, []);
return () => subs.map((unsub) => unsub());
}, []);
return (
<IntermediateContext.Provider value={value}>
<IntermediateActionsContext.Provider value={actions}>
{screen.id !== "onboarding" && props.children}
<Modals
{...value}
{...actions}
key={
screen.id
} /** By specifying a key, we reset state whenever switching screen. */
/>
<Prompt
when={[
"modify_account",
"special_prompt",
"special_input",
"image_viewer",
"profile",
"channel_info",
"pending_requests",
"user_picker",
].includes(screen.id)}
message={(_, action) => {
if (action === "POP") {
openScreen({ id: "none" });
setTimeout(() => history.push(history.location), 0);
return (
<IntermediateContext.Provider value={value}>
<IntermediateActionsContext.Provider value={actions}>
{screen.id !== "onboarding" && props.children}
<Modals
{...value}
{...actions}
key={
screen.id
} /** By specifying a key, we reset state whenever switching screen. */
/>
<Prompt
when={[
"modify_account",
"special_prompt",
"special_input",
"image_viewer",
"profile",
"channel_info",
"pending_requests",
"user_picker",
].includes(screen.id)}
message={(_, action) => {
if (action === "POP") {
openScreen({ id: "none" });
setTimeout(() => history.push(history.location), 0);
return false;
}
return false;
}
return true;
}}
/>
</IntermediateActionsContext.Provider>
</IntermediateContext.Provider>
);
return true;
}}
/>
</IntermediateActionsContext.Provider>
</IntermediateContext.Provider>
);
}
export const useIntermediate = () => useContext(IntermediateActionsContext);

View file

@ -7,27 +7,27 @@ import { PromptModal } from "./modals/Prompt";
import { SignedOutModal } from "./modals/SignedOut";
export interface Props {
screen: Screen;
openScreen: (id: any) => void;
screen: Screen;
openScreen: (id: any) => void;
}
export default function Modals({ screen, openScreen }: Props) {
const onClose = () => openScreen({ id: "none" });
const onClose = () => openScreen({ id: "none" });
switch (screen.id) {
case "_prompt":
return <PromptModal onClose={onClose} {...screen} />;
case "_input":
return <InputModal onClose={onClose} {...screen} />;
case "error":
return <ErrorModal onClose={onClose} {...screen} />;
case "signed_out":
return <SignedOutModal onClose={onClose} {...screen} />;
case "clipboard":
return <ClipboardModal onClose={onClose} {...screen} />;
case "onboarding":
return <OnboardingModal onClose={onClose} {...screen} />;
}
switch (screen.id) {
case "_prompt":
return <PromptModal onClose={onClose} {...screen} />;
case "_input":
return <InputModal onClose={onClose} {...screen} />;
case "error":
return <ErrorModal onClose={onClose} {...screen} />;
case "signed_out":
return <SignedOutModal onClose={onClose} {...screen} />;
case "clipboard":
return <ClipboardModal onClose={onClose} {...screen} />;
case "onboarding":
return <OnboardingModal onClose={onClose} {...screen} />;
}
return null;
return null;
}

View file

@ -11,29 +11,29 @@ import { UserPicker } from "./popovers/UserPicker";
import { UserProfile } from "./popovers/UserProfile";
export default function Popovers() {
const { screen } = useContext(IntermediateContext);
const { openScreen } = useIntermediate();
const { screen } = useContext(IntermediateContext);
const { openScreen } = useIntermediate();
const onClose = () => openScreen({ id: "none" });
const onClose = () => openScreen({ id: "none" });
switch (screen.id) {
case "profile":
return <UserProfile {...screen} onClose={onClose} />;
case "user_picker":
return <UserPicker {...screen} onClose={onClose} />;
case "image_viewer":
return <ImageViewer {...screen} onClose={onClose} />;
case "channel_info":
return <ChannelInfo {...screen} onClose={onClose} />;
case "pending_requests":
return <PendingRequests {...screen} onClose={onClose} />;
case "modify_account":
return <ModifyAccountModal onClose={onClose} {...screen} />;
case "special_prompt":
return <SpecialPromptModal onClose={onClose} {...screen} />;
case "special_input":
return <SpecialInputModal onClose={onClose} {...screen} />;
}
switch (screen.id) {
case "profile":
return <UserProfile {...screen} onClose={onClose} />;
case "user_picker":
return <UserPicker {...screen} onClose={onClose} />;
case "image_viewer":
return <ImageViewer {...screen} onClose={onClose} />;
case "channel_info":
return <ChannelInfo {...screen} onClose={onClose} />;
case "pending_requests":
return <PendingRequests {...screen} onClose={onClose} />;
case "modify_account":
return <ModifyAccountModal onClose={onClose} {...screen} />;
case "special_prompt":
return <SpecialPromptModal onClose={onClose} {...screen} />;
case "special_input":
return <SpecialInputModal onClose={onClose} {...screen} />;
}
return null;
return null;
}

View file

@ -3,30 +3,30 @@ import { Text } from "preact-i18n";
import Modal from "../../../components/ui/Modal";
interface Props {
onClose: () => void;
text: string;
onClose: () => void;
text: string;
}
export function ClipboardModal({ onClose, text }: Props) {
return (
<Modal
visible={true}
onClose={onClose}
title={<Text id="app.special.modals.clipboard.unavailable" />}
actions={[
{
onClick: onClose,
confirmation: true,
text: <Text id="app.special.modals.actions.close" />,
},
]}>
{location.protocol !== "https:" && (
<p>
<Text id="app.special.modals.clipboard.https" />
</p>
)}
<Text id="app.special.modals.clipboard.copy" />{" "}
<code style={{ userSelect: "all" }}>{text}</code>
</Modal>
);
return (
<Modal
visible={true}
onClose={onClose}
title={<Text id="app.special.modals.clipboard.unavailable" />}
actions={[
{
onClick: onClose,
confirmation: true,
text: <Text id="app.special.modals.actions.close" />,
},
]}>
{location.protocol !== "https:" && (
<p>
<Text id="app.special.modals.clipboard.https" />
</p>
)}
<Text id="app.special.modals.clipboard.copy" />{" "}
<code style={{ userSelect: "all" }}>{text}</code>
</Modal>
);
}

View file

@ -3,28 +3,28 @@ import { Text } from "preact-i18n";
import Modal from "../../../components/ui/Modal";
interface Props {
onClose: () => void;
error: string;
onClose: () => void;
error: string;
}
export function ErrorModal({ onClose, error }: Props) {
return (
<Modal
visible={true}
onClose={() => false}
title={<Text id="app.special.modals.error" />}
actions={[
{
onClick: onClose,
confirmation: true,
text: <Text id="app.special.modals.actions.ok" />,
},
{
onClick: () => location.reload(),
text: <Text id="app.special.modals.actions.reload" />,
},
]}>
<Text id={`error.${error}`}>{error}</Text>
</Modal>
);
return (
<Modal
visible={true}
onClose={() => false}
title={<Text id="app.special.modals.error" />}
actions={[
{
onClick: onClose,
confirmation: true,
text: <Text id="app.special.modals.actions.ok" />,
},
{
onClick: () => location.reload(),
text: <Text id="app.special.modals.actions.reload" />,
},
]}>
<Text id={`error.${error}`}>{error}</Text>
</Modal>
);
}

View file

@ -13,164 +13,164 @@ import { AppContext } from "../../revoltjs/RevoltClient";
import { takeError } from "../../revoltjs/util";
interface Props {
onClose: () => void;
question: Children;
field?: Children;
defaultValue?: string;
callback: (value: string) => Promise<void>;
onClose: () => void;
question: Children;
field?: Children;
defaultValue?: string;
callback: (value: string) => Promise<void>;
}
export function InputModal({
onClose,
question,
field,
defaultValue,
callback,
onClose,
question,
field,
defaultValue,
callback,
}: Props) {
const [processing, setProcessing] = useState(false);
const [value, setValue] = useState(defaultValue ?? "");
const [error, setError] = useState<undefined | string>(undefined);
const [processing, setProcessing] = useState(false);
const [value, setValue] = useState(defaultValue ?? "");
const [error, setError] = useState<undefined | string>(undefined);
return (
<Modal
visible={true}
title={question}
disabled={processing}
actions={[
{
confirmation: true,
text: <Text id="app.special.modals.actions.ok" />,
onClick: () => {
setProcessing(true);
callback(value)
.then(onClose)
.catch((err) => {
setError(takeError(err));
setProcessing(false);
});
},
},
{
text: <Text id="app.special.modals.actions.cancel" />,
onClick: onClose,
},
]}
onClose={onClose}>
<form>
{field ? (
<Overline error={error} block>
{field}
</Overline>
) : (
error && <Overline error={error} type="error" block />
)}
<InputBox
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
/>
</form>
</Modal>
);
return (
<Modal
visible={true}
title={question}
disabled={processing}
actions={[
{
confirmation: true,
text: <Text id="app.special.modals.actions.ok" />,
onClick: () => {
setProcessing(true);
callback(value)
.then(onClose)
.catch((err) => {
setError(takeError(err));
setProcessing(false);
});
},
},
{
text: <Text id="app.special.modals.actions.cancel" />,
onClick: onClose,
},
]}
onClose={onClose}>
<form>
{field ? (
<Overline error={error} block>
{field}
</Overline>
) : (
error && <Overline error={error} type="error" block />
)}
<InputBox
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
/>
</form>
</Modal>
);
}
type SpecialProps = { onClose: () => void } & (
| {
type:
| "create_group"
| "create_server"
| "set_custom_status"
| "add_friend";
}
| { type: "create_role"; server: string; callback: (id: string) => void }
| {
type:
| "create_group"
| "create_server"
| "set_custom_status"
| "add_friend";
}
| { type: "create_role"; server: string; callback: (id: string) => void }
);
export function SpecialInputModal(props: SpecialProps) {
const history = useHistory();
const client = useContext(AppContext);
const history = useHistory();
const client = useContext(AppContext);
const { onClose } = props;
switch (props.type) {
case "create_group": {
return (
<InputModal
onClose={onClose}
question={<Text id="app.main.groups.create" />}
field={<Text id="app.main.groups.name" />}
callback={async (name) => {
const group = await client.channels.createGroup({
name,
nonce: ulid(),
users: [],
});
const { onClose } = props;
switch (props.type) {
case "create_group": {
return (
<InputModal
onClose={onClose}
question={<Text id="app.main.groups.create" />}
field={<Text id="app.main.groups.name" />}
callback={async (name) => {
const group = await client.channels.createGroup({
name,
nonce: ulid(),
users: [],
});
history.push(`/channel/${group._id}`);
}}
/>
);
}
case "create_server": {
return (
<InputModal
onClose={onClose}
question={<Text id="app.main.servers.create" />}
field={<Text id="app.main.servers.name" />}
callback={async (name) => {
const server = await client.servers.createServer({
name,
nonce: ulid(),
});
history.push(`/channel/${group._id}`);
}}
/>
);
}
case "create_server": {
return (
<InputModal
onClose={onClose}
question={<Text id="app.main.servers.create" />}
field={<Text id="app.main.servers.name" />}
callback={async (name) => {
const server = await client.servers.createServer({
name,
nonce: ulid(),
});
history.push(`/server/${server._id}`);
}}
/>
);
}
case "create_role": {
return (
<InputModal
onClose={onClose}
question={
<Text id="app.settings.permissions.create_role" />
}
field={<Text id="app.settings.permissions.role_name" />}
callback={async (name) => {
const role = await client.servers.createRole(
props.server,
name,
);
props.callback(role.id);
}}
/>
);
}
case "set_custom_status": {
return (
<InputModal
onClose={onClose}
question={<Text id="app.context_menu.set_custom_status" />}
field={<Text id="app.context_menu.custom_status" />}
defaultValue={client.user?.status?.text}
callback={(text) =>
client.users.editUser({
status: {
...client.user?.status,
text: text.trim().length > 0 ? text : undefined,
},
})
}
/>
);
}
case "add_friend": {
return (
<InputModal
onClose={onClose}
question={"Add Friend"}
callback={(username) => client.users.addFriend(username)}
/>
);
}
default:
return null;
}
history.push(`/server/${server._id}`);
}}
/>
);
}
case "create_role": {
return (
<InputModal
onClose={onClose}
question={
<Text id="app.settings.permissions.create_role" />
}
field={<Text id="app.settings.permissions.role_name" />}
callback={async (name) => {
const role = await client.servers.createRole(
props.server,
name,
);
props.callback(role.id);
}}
/>
);
}
case "set_custom_status": {
return (
<InputModal
onClose={onClose}
question={<Text id="app.context_menu.set_custom_status" />}
field={<Text id="app.context_menu.custom_status" />}
defaultValue={client.user?.status?.text}
callback={(text) =>
client.users.editUser({
status: {
...client.user?.status,
text: text.trim().length > 0 ? text : undefined,
},
})
}
/>
);
}
case "add_friend": {
return (
<InputModal
onClose={onClose}
question={"Add Friend"}
callback={(username) => client.users.addFriend(username)}
/>
);
}
default:
return null;
}
}

View file

@ -12,67 +12,67 @@ import FormField from "../../../pages/login/FormField";
import { takeError } from "../../revoltjs/util";
interface Props {
onClose: () => void;
callback: (username: string, loginAfterSuccess?: true) => Promise<void>;
onClose: () => void;
callback: (username: string, loginAfterSuccess?: true) => Promise<void>;
}
interface FormInputs {
username: string;
username: string;
}
export function OnboardingModal({ onClose, callback }: Props) {
const { handleSubmit, register } = useForm<FormInputs>();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
const { handleSubmit, register } = useForm<FormInputs>();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
const onSubmit: SubmitHandler<FormInputs> = ({ username }) => {
setLoading(true);
callback(username, true)
.then(onClose)
.catch((err: any) => {
setError(takeError(err));
setLoading(false);
});
};
const onSubmit: SubmitHandler<FormInputs> = ({ username }) => {
setLoading(true);
callback(username, true)
.then(onClose)
.catch((err: any) => {
setError(takeError(err));
setLoading(false);
});
};
return (
<div className={styles.onboarding}>
<div className={styles.header}>
<h1>
<Text id="app.special.modals.onboarding.welcome" />
<img src={wideSVG} />
</h1>
</div>
<div className={styles.form}>
{loading ? (
<Preloader type="spinner" />
) : (
<>
<p>
<Text id="app.special.modals.onboarding.pick" />
</p>
<form
onSubmit={
handleSubmit(
onSubmit,
) as JSX.GenericEventHandler<HTMLFormElement>
}>
<div>
<FormField
type="username"
register={register}
showOverline
error={error}
/>
</div>
<Button type="submit">
<Text id="app.special.modals.actions.continue" />
</Button>
</form>
</>
)}
</div>
<div />
</div>
);
return (
<div className={styles.onboarding}>
<div className={styles.header}>
<h1>
<Text id="app.special.modals.onboarding.welcome" />
<img src={wideSVG} />
</h1>
</div>
<div className={styles.form}>
{loading ? (
<Preloader type="spinner" />
) : (
<>
<p>
<Text id="app.special.modals.onboarding.pick" />
</p>
<form
onSubmit={
handleSubmit(
onSubmit,
) as JSX.GenericEventHandler<HTMLFormElement>
}>
<div>
<FormField
type="username"
register={register}
showOverline
error={error}
/>
</div>
<Button type="submit">
<Text id="app.special.modals.actions.continue" />
</Button>
</form>
</>
)}
</div>
<div />
</div>
);
}

View file

@ -21,453 +21,453 @@ import { mapMessage, takeError } from "../../revoltjs/util";
import { useIntermediate } from "../Intermediate";
interface Props {
onClose: () => void;
question: Children;
content?: Children;
disabled?: boolean;
actions: Action[];
error?: string;
onClose: () => void;
question: Children;
content?: Children;
disabled?: boolean;
actions: Action[];
error?: string;
}
export function PromptModal({
onClose,
question,
content,
actions,
disabled,
error,
onClose,
question,
content,
actions,
disabled,
error,
}: Props) {
return (
<Modal
visible={true}
title={question}
actions={actions}
onClose={onClose}
disabled={disabled}>
{error && <Overline error={error} type="error" />}
{content}
</Modal>
);
return (
<Modal
visible={true}
title={question}
actions={actions}
onClose={onClose}
disabled={disabled}>
{error && <Overline error={error} type="error" />}
{content}
</Modal>
);
}
type SpecialProps = { onClose: () => void } & (
| { type: "leave_group"; target: Channels.GroupChannel }
| { type: "close_dm"; target: Channels.DirectMessageChannel }
| { type: "leave_server"; target: Servers.Server }
| { type: "delete_server"; target: Servers.Server }
| { type: "delete_channel"; target: Channels.TextChannel }
| { type: "delete_message"; target: Channels.Message }
| {
type: "create_invite";
target: Channels.TextChannel | Channels.GroupChannel;
}
| { type: "kick_member"; target: Servers.Server; user: string }
| { type: "ban_member"; target: Servers.Server; user: string }
| { type: "unfriend_user"; target: Users.User }
| { type: "block_user"; target: Users.User }
| { type: "create_channel"; target: Servers.Server }
| { type: "leave_group"; target: Channels.GroupChannel }
| { type: "close_dm"; target: Channels.DirectMessageChannel }
| { type: "leave_server"; target: Servers.Server }
| { type: "delete_server"; target: Servers.Server }
| { type: "delete_channel"; target: Channels.TextChannel }
| { type: "delete_message"; target: Channels.Message }
| {
type: "create_invite";
target: Channels.TextChannel | Channels.GroupChannel;
}
| { type: "kick_member"; target: Servers.Server; user: string }
| { type: "ban_member"; target: Servers.Server; user: string }
| { type: "unfriend_user"; target: Users.User }
| { type: "block_user"; target: Users.User }
| { type: "create_channel"; target: Servers.Server }
);
export function SpecialPromptModal(props: SpecialProps) {
const client = useContext(AppContext);
const [processing, setProcessing] = useState(false);
const [error, setError] = useState<undefined | string>(undefined);
const client = useContext(AppContext);
const [processing, setProcessing] = useState(false);
const [error, setError] = useState<undefined | string>(undefined);
const { onClose } = props;
switch (props.type) {
case "leave_group":
case "close_dm":
case "leave_server":
case "delete_server":
case "delete_channel":
case "unfriend_user":
case "block_user": {
const EVENTS = {
close_dm: ["confirm_close_dm", "close"],
delete_server: ["confirm_delete", "delete"],
delete_channel: ["confirm_delete", "delete"],
leave_group: ["confirm_leave", "leave"],
leave_server: ["confirm_leave", "leave"],
unfriend_user: ["unfriend_user", "remove"],
block_user: ["block_user", "block"],
};
const { onClose } = props;
switch (props.type) {
case "leave_group":
case "close_dm":
case "leave_server":
case "delete_server":
case "delete_channel":
case "unfriend_user":
case "block_user": {
const EVENTS = {
close_dm: ["confirm_close_dm", "close"],
delete_server: ["confirm_delete", "delete"],
delete_channel: ["confirm_delete", "delete"],
leave_group: ["confirm_leave", "leave"],
leave_server: ["confirm_leave", "leave"],
unfriend_user: ["unfriend_user", "remove"],
block_user: ["block_user", "block"],
};
let event = EVENTS[props.type];
let name;
switch (props.type) {
case "unfriend_user":
case "block_user":
name = props.target.username;
break;
case "close_dm":
name = client.users.get(
client.channels.getRecipient(props.target._id),
)?.username;
break;
default:
name = props.target.name;
}
let event = EVENTS[props.type];
let name;
switch (props.type) {
case "unfriend_user":
case "block_user":
name = props.target.username;
break;
case "close_dm":
name = client.users.get(
client.channels.getRecipient(props.target._id),
)?.username;
break;
default:
name = props.target.name;
}
return (
<PromptModal
onClose={onClose}
question={
<Text
id={`app.special.modals.prompt.${event[0]}`}
fields={{ name }}
/>
}
actions={[
{
confirmation: true,
contrast: true,
error: true,
text: (
<Text
id={`app.special.modals.actions.${event[1]}`}
/>
),
onClick: async () => {
setProcessing(true);
return (
<PromptModal
onClose={onClose}
question={
<Text
id={`app.special.modals.prompt.${event[0]}`}
fields={{ name }}
/>
}
actions={[
{
confirmation: true,
contrast: true,
error: true,
text: (
<Text
id={`app.special.modals.actions.${event[1]}`}
/>
),
onClick: async () => {
setProcessing(true);
try {
switch (props.type) {
case "unfriend_user":
await client.users.removeFriend(
props.target._id,
);
break;
case "block_user":
await client.users.blockUser(
props.target._id,
);
break;
case "leave_group":
case "close_dm":
case "delete_channel":
await client.channels.delete(
props.target._id,
);
break;
case "leave_server":
case "delete_server":
await client.servers.delete(
props.target._id,
);
break;
}
try {
switch (props.type) {
case "unfriend_user":
await client.users.removeFriend(
props.target._id,
);
break;
case "block_user":
await client.users.blockUser(
props.target._id,
);
break;
case "leave_group":
case "close_dm":
case "delete_channel":
await client.channels.delete(
props.target._id,
);
break;
case "leave_server":
case "delete_server":
await client.servers.delete(
props.target._id,
);
break;
}
onClose();
} catch (err) {
setError(takeError(err));
setProcessing(false);
}
},
},
{
text: (
<Text id="app.special.modals.actions.cancel" />
),
onClick: onClose,
},
]}
content={
<TextReact
id={`app.special.modals.prompt.${event[0]}_long`}
fields={{ name: <b>{name}</b> }}
/>
}
disabled={processing}
error={error}
/>
);
}
case "delete_message": {
return (
<PromptModal
onClose={onClose}
question={<Text id={"app.context_menu.delete_message"} />}
actions={[
{
confirmation: true,
contrast: true,
error: true,
text: (
<Text id="app.special.modals.actions.delete" />
),
onClick: async () => {
setProcessing(true);
onClose();
} catch (err) {
setError(takeError(err));
setProcessing(false);
}
},
},
{
text: (
<Text id="app.special.modals.actions.cancel" />
),
onClick: onClose,
},
]}
content={
<TextReact
id={`app.special.modals.prompt.${event[0]}_long`}
fields={{ name: <b>{name}</b> }}
/>
}
disabled={processing}
error={error}
/>
);
}
case "delete_message": {
return (
<PromptModal
onClose={onClose}
question={<Text id={"app.context_menu.delete_message"} />}
actions={[
{
confirmation: true,
contrast: true,
error: true,
text: (
<Text id="app.special.modals.actions.delete" />
),
onClick: async () => {
setProcessing(true);
try {
await client.channels.deleteMessage(
props.target.channel,
props.target._id,
);
try {
await client.channels.deleteMessage(
props.target.channel,
props.target._id,
);
onClose();
} catch (err) {
setError(takeError(err));
setProcessing(false);
}
},
},
{
text: (
<Text id="app.special.modals.actions.cancel" />
),
onClick: onClose,
},
]}
content={
<>
<Text
id={`app.special.modals.prompt.confirm_delete_message_long`}
/>
<Message
message={mapMessage(props.target)}
head={true}
contrast
/>
</>
}
disabled={processing}
error={error}
/>
);
}
case "create_invite": {
const [code, setCode] = useState("abcdef");
const { writeClipboard } = useIntermediate();
onClose();
} catch (err) {
setError(takeError(err));
setProcessing(false);
}
},
},
{
text: (
<Text id="app.special.modals.actions.cancel" />
),
onClick: onClose,
},
]}
content={
<>
<Text
id={`app.special.modals.prompt.confirm_delete_message_long`}
/>
<Message
message={mapMessage(props.target)}
head={true}
contrast
/>
</>
}
disabled={processing}
error={error}
/>
);
}
case "create_invite": {
const [code, setCode] = useState("abcdef");
const { writeClipboard } = useIntermediate();
useEffect(() => {
setProcessing(true);
useEffect(() => {
setProcessing(true);
client.channels
.createInvite(props.target._id)
.then((code) => setCode(code))
.catch((err) => setError(takeError(err)))
.finally(() => setProcessing(false));
}, []);
client.channels
.createInvite(props.target._id)
.then((code) => setCode(code))
.catch((err) => setError(takeError(err)))
.finally(() => setProcessing(false));
}, []);
return (
<PromptModal
onClose={onClose}
question={<Text id={`app.context_menu.create_invite`} />}
actions={[
{
text: <Text id="app.special.modals.actions.ok" />,
confirmation: true,
onClick: onClose,
},
{
text: <Text id="app.context_menu.copy_link" />,
onClick: () =>
writeClipboard(
`${window.location.protocol}//${window.location.host}/invite/${code}`,
),
},
]}
content={
processing ? (
<Text id="app.special.modals.prompt.create_invite_generate" />
) : (
<div className={styles.invite}>
<Text id="app.special.modals.prompt.create_invite_created" />
<code>{code}</code>
</div>
)
}
disabled={processing}
error={error}
/>
);
}
case "kick_member": {
const user = client.users.get(props.user);
return (
<PromptModal
onClose={onClose}
question={<Text id={`app.context_menu.create_invite`} />}
actions={[
{
text: <Text id="app.special.modals.actions.ok" />,
confirmation: true,
onClick: onClose,
},
{
text: <Text id="app.context_menu.copy_link" />,
onClick: () =>
writeClipboard(
`${window.location.protocol}//${window.location.host}/invite/${code}`,
),
},
]}
content={
processing ? (
<Text id="app.special.modals.prompt.create_invite_generate" />
) : (
<div className={styles.invite}>
<Text id="app.special.modals.prompt.create_invite_created" />
<code>{code}</code>
</div>
)
}
disabled={processing}
error={error}
/>
);
}
case "kick_member": {
const user = client.users.get(props.user);
return (
<PromptModal
onClose={onClose}
question={<Text id={`app.context_menu.kick_member`} />}
actions={[
{
text: <Text id="app.special.modals.actions.kick" />,
contrast: true,
error: true,
confirmation: true,
onClick: async () => {
setProcessing(true);
return (
<PromptModal
onClose={onClose}
question={<Text id={`app.context_menu.kick_member`} />}
actions={[
{
text: <Text id="app.special.modals.actions.kick" />,
contrast: true,
error: true,
confirmation: true,
onClick: async () => {
setProcessing(true);
try {
await client.servers.members.kickMember(
props.target._id,
props.user,
);
onClose();
} catch (err) {
setError(takeError(err));
setProcessing(false);
}
},
},
{
text: (
<Text id="app.special.modals.actions.cancel" />
),
onClick: onClose,
},
]}
content={
<div className={styles.column}>
<UserIcon target={user} size={64} />
<Text
id="app.special.modals.prompt.confirm_kick"
fields={{ name: user?.username }}
/>
</div>
}
disabled={processing}
error={error}
/>
);
}
case "ban_member": {
const [reason, setReason] = useState<string | undefined>(undefined);
const user = client.users.get(props.user);
try {
await client.servers.members.kickMember(
props.target._id,
props.user,
);
onClose();
} catch (err) {
setError(takeError(err));
setProcessing(false);
}
},
},
{
text: (
<Text id="app.special.modals.actions.cancel" />
),
onClick: onClose,
},
]}
content={
<div className={styles.column}>
<UserIcon target={user} size={64} />
<Text
id="app.special.modals.prompt.confirm_kick"
fields={{ name: user?.username }}
/>
</div>
}
disabled={processing}
error={error}
/>
);
}
case "ban_member": {
const [reason, setReason] = useState<string | undefined>(undefined);
const user = client.users.get(props.user);
return (
<PromptModal
onClose={onClose}
question={<Text id={`app.context_menu.ban_member`} />}
actions={[
{
text: <Text id="app.special.modals.actions.ban" />,
contrast: true,
error: true,
confirmation: true,
onClick: async () => {
setProcessing(true);
return (
<PromptModal
onClose={onClose}
question={<Text id={`app.context_menu.ban_member`} />}
actions={[
{
text: <Text id="app.special.modals.actions.ban" />,
contrast: true,
error: true,
confirmation: true,
onClick: async () => {
setProcessing(true);
try {
await client.servers.banUser(
props.target._id,
props.user,
{ reason },
);
onClose();
} catch (err) {
setError(takeError(err));
setProcessing(false);
}
},
},
{
text: (
<Text id="app.special.modals.actions.cancel" />
),
onClick: onClose,
},
]}
content={
<div className={styles.column}>
<UserIcon target={user} size={64} />
<Text
id="app.special.modals.prompt.confirm_ban"
fields={{ name: user?.username }}
/>
<Overline>
<Text id="app.special.modals.prompt.confirm_ban_reason" />
</Overline>
<InputBox
value={reason ?? ""}
onChange={(e) =>
setReason(e.currentTarget.value)
}
/>
</div>
}
disabled={processing}
error={error}
/>
);
}
case "create_channel": {
const [name, setName] = useState("");
const [type, setType] = useState<"Text" | "Voice">("Text");
const history = useHistory();
try {
await client.servers.banUser(
props.target._id,
props.user,
{ reason },
);
onClose();
} catch (err) {
setError(takeError(err));
setProcessing(false);
}
},
},
{
text: (
<Text id="app.special.modals.actions.cancel" />
),
onClick: onClose,
},
]}
content={
<div className={styles.column}>
<UserIcon target={user} size={64} />
<Text
id="app.special.modals.prompt.confirm_ban"
fields={{ name: user?.username }}
/>
<Overline>
<Text id="app.special.modals.prompt.confirm_ban_reason" />
</Overline>
<InputBox
value={reason ?? ""}
onChange={(e) =>
setReason(e.currentTarget.value)
}
/>
</div>
}
disabled={processing}
error={error}
/>
);
}
case "create_channel": {
const [name, setName] = useState("");
const [type, setType] = useState<"Text" | "Voice">("Text");
const history = useHistory();
return (
<PromptModal
onClose={onClose}
question={<Text id="app.context_menu.create_channel" />}
actions={[
{
confirmation: true,
contrast: true,
text: (
<Text id="app.special.modals.actions.create" />
),
onClick: async () => {
setProcessing(true);
return (
<PromptModal
onClose={onClose}
question={<Text id="app.context_menu.create_channel" />}
actions={[
{
confirmation: true,
contrast: true,
text: (
<Text id="app.special.modals.actions.create" />
),
onClick: async () => {
setProcessing(true);
try {
const channel =
await client.servers.createChannel(
props.target._id,
{
type,
name,
nonce: ulid(),
},
);
try {
const channel =
await client.servers.createChannel(
props.target._id,
{
type,
name,
nonce: ulid(),
},
);
history.push(
`/server/${props.target._id}/channel/${channel._id}`,
);
onClose();
} catch (err) {
setError(takeError(err));
setProcessing(false);
}
},
},
{
text: (
<Text id="app.special.modals.actions.cancel" />
),
onClick: onClose,
},
]}
content={
<>
<Overline block type="subtle">
<Text id="app.main.servers.channel_type" />
</Overline>
<Radio
checked={type === "Text"}
onSelect={() => setType("Text")}>
<Text id="app.main.servers.text_channel" />
</Radio>
<Radio
checked={type === "Voice"}
onSelect={() => setType("Voice")}>
<Text id="app.main.servers.voice_channel" />
</Radio>
<Overline block type="subtle">
<Text id="app.main.servers.channel_name" />
</Overline>
<InputBox
value={name}
onChange={(e) => setName(e.currentTarget.value)}
/>
</>
}
disabled={processing}
error={error}
/>
);
}
default:
return null;
}
history.push(
`/server/${props.target._id}/channel/${channel._id}`,
);
onClose();
} catch (err) {
setError(takeError(err));
setProcessing(false);
}
},
},
{
text: (
<Text id="app.special.modals.actions.cancel" />
),
onClick: onClose,
},
]}
content={
<>
<Overline block type="subtle">
<Text id="app.main.servers.channel_type" />
</Overline>
<Radio
checked={type === "Text"}
onSelect={() => setType("Text")}>
<Text id="app.main.servers.text_channel" />
</Radio>
<Radio
checked={type === "Voice"}
onSelect={() => setType("Voice")}>
<Text id="app.main.servers.voice_channel" />
</Radio>
<Overline block type="subtle">
<Text id="app.main.servers.channel_name" />
</Overline>
<InputBox
value={name}
onChange={(e) => setName(e.currentTarget.value)}
/>
</>
}
disabled={processing}
error={error}
/>
);
}
default:
return null;
}
}

View file

@ -3,22 +3,22 @@ import { Text } from "preact-i18n";
import Modal from "../../../components/ui/Modal";
interface Props {
onClose: () => void;
onClose: () => void;
}
export function SignedOutModal({ onClose }: Props) {
return (
<Modal
visible={true}
onClose={onClose}
title={<Text id="app.special.modals.signed_out" />}
actions={[
{
onClick: onClose,
confirmation: true,
text: <Text id="app.special.modals.actions.ok" />,
},
]}
/>
);
return (
<Modal
visible={true}
onClose={onClose}
title={<Text id="app.special.modals.signed_out" />}
actions={[
{
onClick: onClose,
confirmation: true,
text: <Text id="app.special.modals.actions.ok" />,
},
]}
/>
);
}

View file

@ -9,36 +9,36 @@ import { useChannel, useForceUpdate } from "../../revoltjs/hooks";
import { getChannelName } from "../../revoltjs/util";
interface Props {
channel_id: string;
onClose: () => void;
channel_id: string;
onClose: () => void;
}
export function ChannelInfo({ channel_id, onClose }: Props) {
const ctx = useForceUpdate();
const channel = useChannel(channel_id, ctx);
if (!channel) return null;
const ctx = useForceUpdate();
const channel = useChannel(channel_id, ctx);
if (!channel) return null;
if (
channel.channel_type === "DirectMessage" ||
channel.channel_type === "SavedMessages"
) {
onClose();
return null;
}
if (
channel.channel_type === "DirectMessage" ||
channel.channel_type === "SavedMessages"
) {
onClose();
return null;
}
return (
<Modal visible={true} onClose={onClose}>
<div className={styles.info}>
<div className={styles.header}>
<h1>{getChannelName(ctx.client, channel, true)}</h1>
<div onClick={onClose}>
<X size={36} />
</div>
</div>
<p>
<Markdown content={channel.description} />
</p>
</div>
</Modal>
);
return (
<Modal visible={true} onClose={onClose}>
<div className={styles.info}>
<div className={styles.header}>
<h1>{getChannelName(ctx.client, channel, true)}</h1>
<div onClick={onClose}>
<X size={36} />
</div>
</div>
<p>
<Markdown content={channel.description} />
</p>
</div>
</Modal>
);
}

View file

@ -10,37 +10,37 @@ import Modal from "../../../components/ui/Modal";
import { AppContext } from "../../revoltjs/RevoltClient";
interface Props {
onClose: () => void;
embed?: EmbedImage;
attachment?: Attachment;
onClose: () => void;
embed?: EmbedImage;
attachment?: Attachment;
}
export function ImageViewer({ attachment, embed, onClose }: 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 (attachment && attachment.metadata.type !== "Image") return null;
const client = useContext(AppContext);
if (attachment && attachment.metadata.type !== "Image") return null;
const client = useContext(AppContext);
return (
<Modal visible={true} onClose={onClose} noBackground>
<div className={styles.viewer}>
{attachment && (
<>
<img src={client.generateFileURL(attachment)} />
<AttachmentActions attachment={attachment} />
</>
)}
{embed && (
<>
<img src={proxyImage(embed.url)} />
<EmbedMediaActions embed={embed} />
</>
)}
</div>
</Modal>
);
return (
<Modal visible={true} onClose={onClose} noBackground>
<div className={styles.viewer}>
{attachment && (
<>
<img src={client.generateFileURL(attachment)} />
<AttachmentActions attachment={attachment} />
</>
)}
{embed && (
<>
<img src={proxyImage(embed.url)} />
<EmbedMediaActions embed={embed} />
</>
)}
</div>
</Modal>
);
}

View file

@ -11,124 +11,124 @@ import { AppContext } from "../../revoltjs/RevoltClient";
import { takeError } from "../../revoltjs/util";
interface Props {
onClose: () => void;
field: "username" | "email" | "password";
onClose: () => void;
field: "username" | "email" | "password";
}
interface FormInputs {
password: string;
new_email: string;
new_username: string;
new_password: string;
password: string;
new_email: string;
new_username: string;
new_password: string;
// TODO: figure out if this is correct or not
// it wasn't in the types before this was typed but the element itself was there
current_password?: string;
// TODO: figure out if this is correct or not
// it wasn't in the types before this was typed but the element itself was there
current_password?: string;
}
export function ModifyAccountModal({ onClose, field }: Props) {
const client = useContext(AppContext);
const { handleSubmit, register, errors } = useForm<FormInputs>();
const [error, setError] = useState<string | undefined>(undefined);
const client = useContext(AppContext);
const { handleSubmit, register, errors } = useForm<FormInputs>();
const [error, setError] = useState<string | undefined>(undefined);
const onSubmit: SubmitHandler<FormInputs> = async ({
password,
new_username,
new_email,
new_password,
}) => {
try {
if (field === "email") {
await client.req("POST", "/auth/change/email", {
password,
new_email,
});
onClose();
} else if (field === "password") {
await client.req("POST", "/auth/change/password", {
password,
new_password,
});
onClose();
} else if (field === "username") {
await client.req("PATCH", "/users/id/username", {
username: new_username,
password,
});
onClose();
}
} catch (err) {
setError(takeError(err));
}
};
const onSubmit: SubmitHandler<FormInputs> = async ({
password,
new_username,
new_email,
new_password,
}) => {
try {
if (field === "email") {
await client.req("POST", "/auth/change/email", {
password,
new_email,
});
onClose();
} else if (field === "password") {
await client.req("POST", "/auth/change/password", {
password,
new_password,
});
onClose();
} else if (field === "username") {
await client.req("PATCH", "/users/id/username", {
username: new_username,
password,
});
onClose();
}
} catch (err) {
setError(takeError(err));
}
};
return (
<Modal
visible={true}
onClose={onClose}
title={<Text id={`app.special.modals.account.change.${field}`} />}
actions={[
{
confirmation: true,
onClick: handleSubmit(onSubmit),
text:
field === "email" ? (
<Text id="app.special.modals.actions.send_email" />
) : (
<Text id="app.special.modals.actions.update" />
),
},
{
onClick: onClose,
text: <Text id="app.special.modals.actions.close" />,
},
]}>
{/* Preact / React typing incompatabilities */}
<form
onSubmit={
handleSubmit(
onSubmit,
) as JSX.GenericEventHandler<HTMLFormElement>
}>
{field === "email" && (
<FormField
type="email"
name="new_email"
register={register}
showOverline
error={errors.new_email?.message}
/>
)}
{field === "password" && (
<FormField
type="password"
name="new_password"
register={register}
showOverline
error={errors.new_password?.message}
/>
)}
{field === "username" && (
<FormField
type="username"
name="new_username"
register={register}
showOverline
error={errors.new_username?.message}
/>
)}
<FormField
type="current_password"
register={register}
showOverline
error={errors.current_password?.message}
/>
{error && (
<Overline type="error" error={error}>
<Text id="app.special.modals.account.failed" />
</Overline>
)}
</form>
</Modal>
);
return (
<Modal
visible={true}
onClose={onClose}
title={<Text id={`app.special.modals.account.change.${field}`} />}
actions={[
{
confirmation: true,
onClick: handleSubmit(onSubmit),
text:
field === "email" ? (
<Text id="app.special.modals.actions.send_email" />
) : (
<Text id="app.special.modals.actions.update" />
),
},
{
onClick: onClose,
text: <Text id="app.special.modals.actions.close" />,
},
]}>
{/* Preact / React typing incompatabilities */}
<form
onSubmit={
handleSubmit(
onSubmit,
) as JSX.GenericEventHandler<HTMLFormElement>
}>
{field === "email" && (
<FormField
type="email"
name="new_email"
register={register}
showOverline
error={errors.new_email?.message}
/>
)}
{field === "password" && (
<FormField
type="password"
name="new_password"
register={register}
showOverline
error={errors.new_password?.message}
/>
)}
{field === "username" && (
<FormField
type="username"
name="new_username"
register={register}
showOverline
error={errors.new_username?.message}
/>
)}
<FormField
type="current_password"
register={register}
showOverline
error={errors.current_password?.message}
/>
{error && (
<Overline type="error" error={error}>
<Text id="app.special.modals.account.failed" />
</Overline>
)}
</form>
</Modal>
);
}

View file

@ -7,25 +7,25 @@ import { Friend } from "../../../pages/friends/Friend";
import { useUsers } from "../../revoltjs/hooks";
interface Props {
users: string[];
onClose: () => void;
users: string[];
onClose: () => void;
}
export function PendingRequests({ users: ids, onClose }: Props) {
const users = useUsers(ids);
const users = useUsers(ids);
return (
<Modal
visible={true}
title={<Text id="app.special.friends.pending" />}
onClose={onClose}>
<div className={styles.list}>
{users
.filter((x) => typeof x !== "undefined")
.map((x) => (
<Friend user={x!} key={x!._id} />
))}
</div>
</Modal>
);
return (
<Modal
visible={true}
title={<Text id="app.special.friends.pending" />}
onClose={onClose}>
<div className={styles.list}>
{users
.filter((x) => typeof x !== "undefined")
.map((x) => (
<Friend user={x!} key={x!._id} />
))}
</div>
</Modal>
);
}

View file

@ -10,59 +10,59 @@ import Modal from "../../../components/ui/Modal";
import { useUsers } from "../../revoltjs/hooks";
interface Props {
omit?: string[];
onClose: () => void;
callback: (users: string[]) => Promise<void>;
omit?: string[];
onClose: () => void;
callback: (users: string[]) => Promise<void>;
}
export function UserPicker(props: Props) {
const [selected, setSelected] = useState<string[]>([]);
const omit = [...(props.omit || []), "00000000000000000000000000"];
const [selected, setSelected] = useState<string[]>([]);
const omit = [...(props.omit || []), "00000000000000000000000000"];
const users = useUsers();
const users = useUsers();
return (
<Modal
visible={true}
title={<Text id="app.special.popovers.user_picker.select" />}
onClose={props.onClose}
actions={[
{
text: <Text id="app.special.modals.actions.ok" />,
onClick: () => props.callback(selected).then(props.onClose),
},
]}>
<div className={styles.list}>
{(
users.filter(
(x) =>
x &&
x.relationship === Users.Relationship.Friend &&
!omit.includes(x._id),
) as User[]
)
.map((x) => {
return {
...x,
selected: selected.includes(x._id),
};
})
.map((x) => (
<UserCheckbox
user={x}
checked={x.selected}
onChange={(v) => {
if (v) {
setSelected([...selected, x._id]);
} else {
setSelected(
selected.filter((y) => y !== x._id),
);
}
}}
/>
))}
</div>
</Modal>
);
return (
<Modal
visible={true}
title={<Text id="app.special.popovers.user_picker.select" />}
onClose={props.onClose}
actions={[
{
text: <Text id="app.special.modals.actions.ok" />,
onClick: () => props.callback(selected).then(props.onClose),
},
]}>
<div className={styles.list}>
{(
users.filter(
(x) =>
x &&
x.relationship === Users.Relationship.Friend &&
!omit.includes(x._id),
) as User[]
)
.map((x) => {
return {
...x,
selected: selected.includes(x._id),
};
})
.map((x) => (
<UserCheckbox
user={x}
checked={x.selected}
onChange={(v) => {
if (v) {
setSelected([...selected, x._id]);
} else {
setSelected(
selected.filter((y) => y !== x._id),
);
}
}}
/>
))}
</div>
</Modal>
);
}

View file

@ -1,9 +1,9 @@
import {
Envelope,
Edit,
UserPlus,
Shield,
Money,
Envelope,
Edit,
UserPlus,
Shield,
Money,
} from "@styled-icons/boxicons-regular";
import { Link, useHistory } from "react-router-dom";
import { Users } from "revolt.js/dist/api/objects";
@ -25,338 +25,338 @@ import Preloader from "../../../components/ui/Preloader";
import Markdown from "../../../components/markdown/Markdown";
import {
AppContext,
ClientStatus,
StatusContext,
AppContext,
ClientStatus,
StatusContext,
} from "../../revoltjs/RevoltClient";
import {
useChannels,
useForceUpdate,
useUserPermission,
useUsers,
useChannels,
useForceUpdate,
useUserPermission,
useUsers,
} from "../../revoltjs/hooks";
import { useIntermediate } from "../Intermediate";
interface Props {
user_id: string;
dummy?: boolean;
onClose: () => void;
dummyProfile?: Users.Profile;
user_id: string;
dummy?: boolean;
onClose: () => void;
dummyProfile?: Users.Profile;
}
enum Badges {
Developer = 1,
Translator = 2,
Supporter = 4,
ResponsibleDisclosure = 8,
EarlyAdopter = 256,
Developer = 1,
Translator = 2,
Supporter = 4,
ResponsibleDisclosure = 8,
EarlyAdopter = 256,
}
export function UserProfile({ user_id, onClose, dummy, dummyProfile }: Props) {
const { openScreen, writeClipboard } = useIntermediate();
const { openScreen, writeClipboard } = useIntermediate();
const [profile, setProfile] = useState<undefined | null | Users.Profile>(
undefined,
);
const [mutual, setMutual] = useState<
undefined | null | Route<"GET", "/users/id/mutual">["response"]
>(undefined);
const [profile, setProfile] = useState<undefined | null | Users.Profile>(
undefined,
);
const [mutual, setMutual] = useState<
undefined | null | Route<"GET", "/users/id/mutual">["response"]
>(undefined);
const history = useHistory();
const client = useContext(AppContext);
const status = useContext(StatusContext);
const [tab, setTab] = useState("profile");
const history = useHistory();
const client = useContext(AppContext);
const status = useContext(StatusContext);
const [tab, setTab] = useState("profile");
const ctx = useForceUpdate();
const all_users = useUsers(undefined, ctx);
const channels = useChannels(undefined, ctx);
const ctx = useForceUpdate();
const all_users = useUsers(undefined, ctx);
const channels = useChannels(undefined, ctx);
const user = all_users.find((x) => x!._id === user_id);
const users = mutual?.users
? all_users.filter((x) => mutual.users.includes(x!._id))
: undefined;
const user = all_users.find((x) => x!._id === user_id);
const users = mutual?.users
? all_users.filter((x) => mutual.users.includes(x!._id))
: undefined;
if (!user) {
useEffect(onClose, []);
return null;
}
if (!user) {
useEffect(onClose, []);
return null;
}
const permissions = useUserPermission(user!._id, ctx);
const permissions = useUserPermission(user!._id, ctx);
useLayoutEffect(() => {
if (!user_id) return;
if (typeof profile !== "undefined") setProfile(undefined);
if (typeof mutual !== "undefined") setMutual(undefined);
}, [user_id]);
useLayoutEffect(() => {
if (!user_id) return;
if (typeof profile !== "undefined") setProfile(undefined);
if (typeof mutual !== "undefined") setMutual(undefined);
}, [user_id]);
if (dummy) {
useLayoutEffect(() => {
setProfile(dummyProfile);
}, [dummyProfile]);
}
if (dummy) {
useLayoutEffect(() => {
setProfile(dummyProfile);
}, [dummyProfile]);
}
useEffect(() => {
if (dummy) return;
if (status === ClientStatus.ONLINE && typeof mutual === "undefined") {
setMutual(null);
client.users.fetchMutual(user_id).then((data) => setMutual(data));
}
}, [mutual, status]);
useEffect(() => {
if (dummy) return;
if (status === ClientStatus.ONLINE && typeof mutual === "undefined") {
setMutual(null);
client.users.fetchMutual(user_id).then((data) => setMutual(data));
}
}, [mutual, status]);
useEffect(() => {
if (dummy) return;
if (status === ClientStatus.ONLINE && typeof profile === "undefined") {
setProfile(null);
useEffect(() => {
if (dummy) return;
if (status === ClientStatus.ONLINE && typeof profile === "undefined") {
setProfile(null);
if (permissions & UserPermission.ViewProfile) {
client.users
.fetchProfile(user_id)
.then((data) => setProfile(data))
.catch(() => {});
}
}
}, [profile, status]);
if (permissions & UserPermission.ViewProfile) {
client.users
.fetchProfile(user_id)
.then((data) => setProfile(data))
.catch(() => {});
}
}
}, [profile, status]);
const mutualGroups = channels.filter(
(channel) =>
channel?.channel_type === "Group" &&
channel.recipients.includes(user_id),
);
const mutualGroups = channels.filter(
(channel) =>
channel?.channel_type === "Group" &&
channel.recipients.includes(user_id),
);
const backgroundURL =
profile &&
client.users.getBackgroundURL(profile, { width: 1000 }, true);
const badges =
(user.badges ?? 0) |
(decodeTime(user._id) < 1623751765790 ? Badges.EarlyAdopter : 0);
const backgroundURL =
profile &&
client.users.getBackgroundURL(profile, { width: 1000 }, true);
const badges =
(user.badges ?? 0) |
(decodeTime(user._id) < 1623751765790 ? Badges.EarlyAdopter : 0);
return (
<Modal
visible
border={dummy}
padding={false}
onClose={onClose}
dontModal={dummy}>
<div
className={styles.header}
data-force={profile?.background ? "light" : undefined}
style={{
backgroundImage:
backgroundURL &&
`linear-gradient( rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.7) ), url('${backgroundURL}')`,
}}>
<div className={styles.profile}>
<UserIcon size={80} target={user} status />
<div className={styles.details}>
<Localizer>
<span
className={styles.username}
onClick={() => writeClipboard(user.username)}>
@{user.username}
</span>
</Localizer>
{user.status?.text && (
<span className={styles.status}>
<UserStatus user={user} />
</span>
)}
</div>
{user.relationship === Users.Relationship.Friend && (
<Localizer>
<Tooltip
content={
<Text id="app.context_menu.message_user" />
}>
<IconButton
onClick={() => {
onClose();
history.push(`/open/${user_id}`);
}}>
<Envelope size={30} />
</IconButton>
</Tooltip>
</Localizer>
)}
{user.relationship === Users.Relationship.User && (
<IconButton
onClick={() => {
onClose();
if (dummy) return;
history.push(`/settings/profile`);
}}>
<Edit size={28} />
</IconButton>
)}
{(user.relationship === Users.Relationship.Incoming ||
user.relationship === Users.Relationship.None) && (
<IconButton
onClick={() =>
client.users.addFriend(user.username)
}>
<UserPlus size={28} />
</IconButton>
)}
</div>
<div className={styles.tabs}>
<div
data-active={tab === "profile"}
onClick={() => setTab("profile")}>
<Text id="app.special.popovers.user_profile.profile" />
</div>
{user.relationship !== Users.Relationship.User && (
<>
<div
data-active={tab === "friends"}
onClick={() => setTab("friends")}>
<Text id="app.special.popovers.user_profile.mutual_friends" />
</div>
<div
data-active={tab === "groups"}
onClick={() => setTab("groups")}>
<Text id="app.special.popovers.user_profile.mutual_groups" />
</div>
</>
)}
</div>
</div>
<div className={styles.content}>
{tab === "profile" && (
<div>
{!(profile?.content || badges > 0) && (
<div className={styles.empty}>
<Text id="app.special.popovers.user_profile.empty" />
</div>
)}
{badges > 0 && (
<div className={styles.category}>
<Text id="app.special.popovers.user_profile.sub.badges" />
</div>
)}
{badges > 0 && (
<div className={styles.badges}>
<Localizer>
{badges & Badges.Developer ? (
<Tooltip
content={
<Text id="app.navigation.tabs.dev" />
}>
<img src="/assets/badges/developer.svg" />
</Tooltip>
) : (
<></>
)}
{badges & Badges.Translator ? (
<Tooltip
content={
<Text id="app.special.popovers.user_profile.badges.translator" />
}>
<img src="/assets/badges/translator.svg" />
</Tooltip>
) : (
<></>
)}
{badges & Badges.EarlyAdopter ? (
<Tooltip
content={
<Text id="app.special.popovers.user_profile.badges.early_adopter" />
}>
<img src="/assets/badges/early_adopter.svg" />
</Tooltip>
) : (
<></>
)}
{badges & Badges.Supporter ? (
<Tooltip
content={
<Text id="app.special.popovers.user_profile.badges.supporter" />
}>
<Money size={32} color="#efab44" />
</Tooltip>
) : (
<></>
)}
{badges & Badges.ResponsibleDisclosure ? (
<Tooltip
content={
<Text id="app.special.popovers.user_profile.badges.responsible_disclosure" />
}>
<Shield size={32} color="gray" />
</Tooltip>
) : (
<></>
)}
</Localizer>
</div>
)}
{profile?.content && (
<div className={styles.category}>
<Text id="app.special.popovers.user_profile.sub.information" />
</div>
)}
<Markdown content={profile?.content} />
{/*<div className={styles.category}><Text id="app.special.popovers.user_profile.sub.connections" /></div>*/}
</div>
)}
{tab === "friends" &&
(users ? (
<div className={styles.entries}>
{users.length === 0 ? (
<div className={styles.empty}>
<Text id="app.special.popovers.user_profile.no_users" />
</div>
) : (
users.map(
(x) =>
x && (
<div
onClick={() =>
openScreen({
id: "profile",
user_id: x._id,
})
}
className={styles.entry}
key={x._id}>
<UserIcon
size={32}
target={x}
/>
<span>{x.username}</span>
</div>
),
)
)}
</div>
) : (
<Preloader type="ring" />
))}
{tab === "groups" && (
<div className={styles.entries}>
{mutualGroups.length === 0 ? (
<div className={styles.empty}>
<Text id="app.special.popovers.user_profile.no_groups" />
</div>
) : (
mutualGroups.map(
(x) =>
x?.channel_type === "Group" && (
<Link to={`/channel/${x._id}`}>
<div
className={styles.entry}
key={x._id}>
<ChannelIcon
target={x}
size={32}
/>
<span>{x.name}</span>
</div>
</Link>
),
)
)}
</div>
)}
</div>
</Modal>
);
return (
<Modal
visible
border={dummy}
padding={false}
onClose={onClose}
dontModal={dummy}>
<div
className={styles.header}
data-force={profile?.background ? "light" : undefined}
style={{
backgroundImage:
backgroundURL &&
`linear-gradient( rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.7) ), url('${backgroundURL}')`,
}}>
<div className={styles.profile}>
<UserIcon size={80} target={user} status />
<div className={styles.details}>
<Localizer>
<span
className={styles.username}
onClick={() => writeClipboard(user.username)}>
@{user.username}
</span>
</Localizer>
{user.status?.text && (
<span className={styles.status}>
<UserStatus user={user} />
</span>
)}
</div>
{user.relationship === Users.Relationship.Friend && (
<Localizer>
<Tooltip
content={
<Text id="app.context_menu.message_user" />
}>
<IconButton
onClick={() => {
onClose();
history.push(`/open/${user_id}`);
}}>
<Envelope size={30} />
</IconButton>
</Tooltip>
</Localizer>
)}
{user.relationship === Users.Relationship.User && (
<IconButton
onClick={() => {
onClose();
if (dummy) return;
history.push(`/settings/profile`);
}}>
<Edit size={28} />
</IconButton>
)}
{(user.relationship === Users.Relationship.Incoming ||
user.relationship === Users.Relationship.None) && (
<IconButton
onClick={() =>
client.users.addFriend(user.username)
}>
<UserPlus size={28} />
</IconButton>
)}
</div>
<div className={styles.tabs}>
<div
data-active={tab === "profile"}
onClick={() => setTab("profile")}>
<Text id="app.special.popovers.user_profile.profile" />
</div>
{user.relationship !== Users.Relationship.User && (
<>
<div
data-active={tab === "friends"}
onClick={() => setTab("friends")}>
<Text id="app.special.popovers.user_profile.mutual_friends" />
</div>
<div
data-active={tab === "groups"}
onClick={() => setTab("groups")}>
<Text id="app.special.popovers.user_profile.mutual_groups" />
</div>
</>
)}
</div>
</div>
<div className={styles.content}>
{tab === "profile" && (
<div>
{!(profile?.content || badges > 0) && (
<div className={styles.empty}>
<Text id="app.special.popovers.user_profile.empty" />
</div>
)}
{badges > 0 && (
<div className={styles.category}>
<Text id="app.special.popovers.user_profile.sub.badges" />
</div>
)}
{badges > 0 && (
<div className={styles.badges}>
<Localizer>
{badges & Badges.Developer ? (
<Tooltip
content={
<Text id="app.navigation.tabs.dev" />
}>
<img src="/assets/badges/developer.svg" />
</Tooltip>
) : (
<></>
)}
{badges & Badges.Translator ? (
<Tooltip
content={
<Text id="app.special.popovers.user_profile.badges.translator" />
}>
<img src="/assets/badges/translator.svg" />
</Tooltip>
) : (
<></>
)}
{badges & Badges.EarlyAdopter ? (
<Tooltip
content={
<Text id="app.special.popovers.user_profile.badges.early_adopter" />
}>
<img src="/assets/badges/early_adopter.svg" />
</Tooltip>
) : (
<></>
)}
{badges & Badges.Supporter ? (
<Tooltip
content={
<Text id="app.special.popovers.user_profile.badges.supporter" />
}>
<Money size={32} color="#efab44" />
</Tooltip>
) : (
<></>
)}
{badges & Badges.ResponsibleDisclosure ? (
<Tooltip
content={
<Text id="app.special.popovers.user_profile.badges.responsible_disclosure" />
}>
<Shield size={32} color="gray" />
</Tooltip>
) : (
<></>
)}
</Localizer>
</div>
)}
{profile?.content && (
<div className={styles.category}>
<Text id="app.special.popovers.user_profile.sub.information" />
</div>
)}
<Markdown content={profile?.content} />
{/*<div className={styles.category}><Text id="app.special.popovers.user_profile.sub.connections" /></div>*/}
</div>
)}
{tab === "friends" &&
(users ? (
<div className={styles.entries}>
{users.length === 0 ? (
<div className={styles.empty}>
<Text id="app.special.popovers.user_profile.no_users" />
</div>
) : (
users.map(
(x) =>
x && (
<div
onClick={() =>
openScreen({
id: "profile",
user_id: x._id,
})
}
className={styles.entry}
key={x._id}>
<UserIcon
size={32}
target={x}
/>
<span>{x.username}</span>
</div>
),
)
)}
</div>
) : (
<Preloader type="ring" />
))}
{tab === "groups" && (
<div className={styles.entries}>
{mutualGroups.length === 0 ? (
<div className={styles.empty}>
<Text id="app.special.popovers.user_profile.no_groups" />
</div>
) : (
mutualGroups.map(
(x) =>
x?.channel_type === "Group" && (
<Link to={`/channel/${x._id}`}>
<div
className={styles.entry}
key={x._id}>
<ChannelIcon
target={x}
size={32}
/>
<span>{x.name}</span>
</div>
</Link>
),
)
)}
</div>
)}
</div>
</Modal>
);
}

View file

@ -6,18 +6,18 @@ import { Children } from "../../types/Preact";
import { OperationsContext } from "./RevoltClient";
interface Props {
auth?: boolean;
children: Children;
auth?: boolean;
children: Children;
}
export const CheckAuth = (props: Props) => {
const operations = useContext(OperationsContext);
const operations = useContext(OperationsContext);
if (props.auth && !operations.ready()) {
return <Redirect to="/login" />;
} else if (!props.auth && operations.ready()) {
return <Redirect to="/" />;
}
if (props.auth && !operations.ready()) {
return <Redirect to="/login" />;
} else if (!props.auth && operations.ready()) {
return <Redirect to="/" />;
}
return <>{props.children}</>;
return <>{props.children}</>;
};

View file

@ -17,276 +17,276 @@ import { AppContext } from "./RevoltClient";
import { takeError } from "./util";
type Props = {
maxFileSize: number;
remove: () => Promise<void>;
fileType: "backgrounds" | "icons" | "avatars" | "attachments" | "banners";
maxFileSize: number;
remove: () => Promise<void>;
fileType: "backgrounds" | "icons" | "avatars" | "attachments" | "banners";
} & (
| { behaviour: "ask"; onChange: (file: File) => void }
| { behaviour: "upload"; onUpload: (id: string) => Promise<void> }
| {
behaviour: "multi";
onChange: (files: File[]) => void;
append?: (files: File[]) => void;
}
| { behaviour: "ask"; onChange: (file: File) => void }
| { behaviour: "upload"; onUpload: (id: string) => Promise<void> }
| {
behaviour: "multi";
onChange: (files: File[]) => void;
append?: (files: File[]) => void;
}
) &
(
| {
style: "icon" | "banner";
defaultPreview?: string;
previewURL?: string;
width?: number;
height?: number;
}
| {
style: "attachment";
attached: boolean;
uploading: boolean;
cancel: () => void;
size?: number;
}
);
(
| {
style: "icon" | "banner";
defaultPreview?: string;
previewURL?: string;
width?: number;
height?: number;
}
| {
style: "attachment";
attached: boolean;
uploading: boolean;
cancel: () => void;
size?: number;
}
);
export async function uploadFile(
autumnURL: string,
tag: string,
file: File,
config?: AxiosRequestConfig,
autumnURL: string,
tag: string,
file: File,
config?: AxiosRequestConfig,
) {
const formData = new FormData();
formData.append("file", file);
const formData = new FormData();
formData.append("file", file);
const res = await Axios.post(autumnURL + "/" + tag, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
...config,
});
const res = await Axios.post(autumnURL + "/" + tag, formData, {
headers: {
"Content-Type": "multipart/form-data",
},
...config,
});
return res.data.id;
return res.data.id;
}
export function grabFiles(
maxFileSize: number,
cb: (files: File[]) => void,
tooLarge: () => void,
multiple?: boolean,
maxFileSize: number,
cb: (files: File[]) => void,
tooLarge: () => void,
multiple?: boolean,
) {
const input = document.createElement("input");
input.type = "file";
input.multiple = multiple ?? false;
const input = document.createElement("input");
input.type = "file";
input.multiple = multiple ?? false;
input.onchange = async (e) => {
const files = (e.currentTarget as HTMLInputElement)?.files;
if (!files) return;
for (let file of files) {
if (file.size > maxFileSize) {
return tooLarge();
}
}
input.onchange = async (e) => {
const files = (e.currentTarget as HTMLInputElement)?.files;
if (!files) return;
for (let file of files) {
if (file.size > maxFileSize) {
return tooLarge();
}
}
cb(Array.from(files));
};
cb(Array.from(files));
};
input.click();
input.click();
}
export function FileUploader(props: Props) {
const { fileType, maxFileSize, remove } = props;
const { openScreen } = useIntermediate();
const client = useContext(AppContext);
const { fileType, maxFileSize, remove } = props;
const { openScreen } = useIntermediate();
const client = useContext(AppContext);
const [uploading, setUploading] = useState(false);
const [uploading, setUploading] = useState(false);
function onClick() {
if (uploading) return;
function onClick() {
if (uploading) return;
grabFiles(
maxFileSize,
async (files) => {
setUploading(true);
grabFiles(
maxFileSize,
async (files) => {
setUploading(true);
try {
if (props.behaviour === "multi") {
props.onChange(files);
} else if (props.behaviour === "ask") {
props.onChange(files[0]);
} else {
await props.onUpload(
await uploadFile(
client.configuration!.features.autumn.url,
fileType,
files[0],
),
);
}
} catch (err) {
return openScreen({ id: "error", error: takeError(err) });
} finally {
setUploading(false);
}
},
() => openScreen({ id: "error", error: "FileTooLarge" }),
props.behaviour === "multi",
);
}
try {
if (props.behaviour === "multi") {
props.onChange(files);
} else if (props.behaviour === "ask") {
props.onChange(files[0]);
} else {
await props.onUpload(
await uploadFile(
client.configuration!.features.autumn.url,
fileType,
files[0],
),
);
}
} catch (err) {
return openScreen({ id: "error", error: takeError(err) });
} finally {
setUploading(false);
}
},
() => openScreen({ id: "error", error: "FileTooLarge" }),
props.behaviour === "multi",
);
}
function removeOrUpload() {
if (uploading) return;
function removeOrUpload() {
if (uploading) return;
if (props.style === "attachment") {
if (props.attached) {
props.remove();
} else {
onClick();
}
} else {
if (props.previewURL) {
props.remove();
} else {
onClick();
}
}
}
if (props.style === "attachment") {
if (props.attached) {
props.remove();
} else {
onClick();
}
} else {
if (props.previewURL) {
props.remove();
} else {
onClick();
}
}
}
if (props.behaviour === "multi" && props.append) {
useEffect(() => {
// File pasting.
function paste(e: ClipboardEvent) {
const items = e.clipboardData?.items;
if (typeof items === "undefined") return;
if (props.behaviour !== "multi" || !props.append) return;
if (props.behaviour === "multi" && props.append) {
useEffect(() => {
// File pasting.
function paste(e: ClipboardEvent) {
const items = e.clipboardData?.items;
if (typeof items === "undefined") return;
if (props.behaviour !== "multi" || !props.append) return;
let files = [];
for (const item of items) {
if (!item.type.startsWith("text/")) {
const blob = item.getAsFile();
if (blob) {
if (blob.size > props.maxFileSize) {
openScreen({
id: "error",
error: "FileTooLarge",
});
}
let files = [];
for (const item of items) {
if (!item.type.startsWith("text/")) {
const blob = item.getAsFile();
if (blob) {
if (blob.size > props.maxFileSize) {
openScreen({
id: "error",
error: "FileTooLarge",
});
}
files.push(blob);
}
}
}
files.push(blob);
}
}
}
props.append(files);
}
props.append(files);
}
// Let the browser know we can drop files.
function dragover(e: DragEvent) {
e.stopPropagation();
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
}
// Let the browser know we can drop files.
function dragover(e: DragEvent) {
e.stopPropagation();
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
}
// File dropping.
function drop(e: DragEvent) {
e.preventDefault();
if (props.behaviour !== "multi" || !props.append) return;
// File dropping.
function drop(e: DragEvent) {
e.preventDefault();
if (props.behaviour !== "multi" || !props.append) return;
const dropped = e.dataTransfer?.files;
if (dropped) {
let files = [];
for (const item of dropped) {
if (item.size > props.maxFileSize) {
openScreen({ id: "error", error: "FileTooLarge" });
}
const dropped = e.dataTransfer?.files;
if (dropped) {
let files = [];
for (const item of dropped) {
if (item.size > props.maxFileSize) {
openScreen({ id: "error", error: "FileTooLarge" });
}
files.push(item);
}
files.push(item);
}
props.append(files);
}
}
props.append(files);
}
}
document.addEventListener("paste", paste);
document.addEventListener("dragover", dragover);
document.addEventListener("drop", drop);
document.addEventListener("paste", paste);
document.addEventListener("dragover", dragover);
document.addEventListener("drop", drop);
return () => {
document.removeEventListener("paste", paste);
document.removeEventListener("dragover", dragover);
document.removeEventListener("drop", drop);
};
}, [props.append]);
}
return () => {
document.removeEventListener("paste", paste);
document.removeEventListener("dragover", dragover);
document.removeEventListener("drop", drop);
};
}, [props.append]);
}
if (props.style === "icon" || props.style === "banner") {
const { style, previewURL, defaultPreview, width, height } = props;
return (
<div
className={classNames(styles.uploader, {
[styles.icon]: style === "icon",
[styles.banner]: style === "banner",
})}
data-uploading={uploading}>
<div
className={styles.image}
style={{
backgroundImage:
style === "icon"
? `url('${previewURL ?? defaultPreview}')`
: previewURL
? `linear-gradient( rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5) ), url('${previewURL}')`
: "black",
width,
height,
}}
onClick={onClick}>
{uploading ? (
<div className={styles.uploading}>
<Preloader type="ring" />
</div>
) : (
<div className={styles.edit}>
<Pencil size={30} />
</div>
)}
</div>
<div className={styles.modify}>
<span onClick={removeOrUpload}>
{uploading ? (
<Text id="app.main.channel.uploading_file" />
) : props.previewURL ? (
<Text id="app.settings.actions.remove" />
) : (
<Text id="app.settings.actions.upload" />
)}
</span>
<span className={styles.small}>
<Text
id="app.settings.actions.max_filesize"
fields={{
filesize: determineFileSize(maxFileSize),
}}
/>
</span>
</div>
</div>
);
} else if (props.style === "attachment") {
const { attached, uploading, cancel, size } = props;
return (
<IconButton
onClick={() => {
if (uploading) return cancel();
if (attached) return remove();
onClick();
}}>
{uploading ? (
<XCircle size={size} />
) : attached ? (
<X size={size} />
) : (
<Plus size={size} />
)}
</IconButton>
);
}
if (props.style === "icon" || props.style === "banner") {
const { style, previewURL, defaultPreview, width, height } = props;
return (
<div
className={classNames(styles.uploader, {
[styles.icon]: style === "icon",
[styles.banner]: style === "banner",
})}
data-uploading={uploading}>
<div
className={styles.image}
style={{
backgroundImage:
style === "icon"
? `url('${previewURL ?? defaultPreview}')`
: previewURL
? `linear-gradient( rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5) ), url('${previewURL}')`
: "black",
width,
height,
}}
onClick={onClick}>
{uploading ? (
<div className={styles.uploading}>
<Preloader type="ring" />
</div>
) : (
<div className={styles.edit}>
<Pencil size={30} />
</div>
)}
</div>
<div className={styles.modify}>
<span onClick={removeOrUpload}>
{uploading ? (
<Text id="app.main.channel.uploading_file" />
) : props.previewURL ? (
<Text id="app.settings.actions.remove" />
) : (
<Text id="app.settings.actions.upload" />
)}
</span>
<span className={styles.small}>
<Text
id="app.settings.actions.max_filesize"
fields={{
filesize: determineFileSize(maxFileSize),
}}
/>
</span>
</div>
</div>
);
} else if (props.style === "attachment") {
const { attached, uploading, cancel, size } = props;
return (
<IconButton
onClick={() => {
if (uploading) return cancel();
if (attached) return remove();
onClick();
}}>
{uploading ? (
<XCircle size={size} />
) : attached ? (
<X size={size} />
) : (
<Plus size={size} />
)}
</IconButton>
);
}
return null;
return null;
}

View file

@ -9,9 +9,9 @@ import { useTranslation } from "../../lib/i18n";
import { connectState } from "../../redux/connector";
import {
getNotificationState,
Notifications,
shouldNotify,
getNotificationState,
Notifications,
shouldNotify,
} from "../../redux/reducers/notifications";
import { NotificationOptions } from "../../redux/reducers/settings";
@ -19,268 +19,268 @@ import { SoundContext } from "../Settings";
import { AppContext } from "./RevoltClient";
interface Props {
options?: NotificationOptions;
notifs: Notifications;
options?: NotificationOptions;
notifs: Notifications;
}
const notifications: { [key: string]: Notification } = {};
async function createNotification(
title: string,
options: globalThis.NotificationOptions,
title: string,
options: globalThis.NotificationOptions,
) {
try {
return new Notification(title, options);
} catch (err) {
let sw = await navigator.serviceWorker.getRegistration();
sw?.showNotification(title, options);
}
try {
return new Notification(title, options);
} catch (err) {
let sw = await navigator.serviceWorker.getRegistration();
sw?.showNotification(title, options);
}
}
function Notifier({ options, notifs }: Props) {
const translate = useTranslation();
const showNotification = options?.desktopEnabled ?? false;
const translate = useTranslation();
const showNotification = options?.desktopEnabled ?? false;
const client = useContext(AppContext);
const { guild: guild_id, channel: channel_id } = useParams<{
guild: string;
channel: string;
}>();
const history = useHistory();
const playSound = useContext(SoundContext);
const client = useContext(AppContext);
const { guild: guild_id, channel: channel_id } = useParams<{
guild: string;
channel: string;
}>();
const history = useHistory();
const playSound = useContext(SoundContext);
async function message(msg: Message) {
if (msg.author === client.user!._id) return;
if (msg.channel === channel_id && document.hasFocus()) return;
if (client.user!.status?.presence === Users.Presence.Busy) return;
async function message(msg: Message) {
if (msg.author === client.user!._id) return;
if (msg.channel === channel_id && document.hasFocus()) return;
if (client.user!.status?.presence === Users.Presence.Busy) return;
const channel = client.channels.get(msg.channel);
const author = client.users.get(msg.author);
if (!channel) return;
if (author?.relationship === Users.Relationship.Blocked) return;
const channel = client.channels.get(msg.channel);
const author = client.users.get(msg.author);
if (!channel) return;
if (author?.relationship === Users.Relationship.Blocked) return;
const notifState = getNotificationState(notifs, channel);
if (!shouldNotify(notifState, msg, client.user!._id)) return;
const notifState = getNotificationState(notifs, channel);
if (!shouldNotify(notifState, msg, client.user!._id)) return;
playSound("message");
if (!showNotification) return;
playSound("message");
if (!showNotification) return;
let title;
switch (channel.channel_type) {
case "SavedMessages":
return;
case "DirectMessage":
title = `@${author?.username}`;
break;
case "Group":
if (author?._id === SYSTEM_USER_ID) {
title = channel.name;
} else {
title = `@${author?.username} - ${channel.name}`;
}
break;
case "TextChannel":
const server = client.servers.get(channel.server);
title = `@${author?.username} (#${channel.name}, ${server?.name})`;
break;
default:
title = msg.channel;
break;
}
let title;
switch (channel.channel_type) {
case "SavedMessages":
return;
case "DirectMessage":
title = `@${author?.username}`;
break;
case "Group":
if (author?._id === SYSTEM_USER_ID) {
title = channel.name;
} else {
title = `@${author?.username} - ${channel.name}`;
}
break;
case "TextChannel":
const server = client.servers.get(channel.server);
title = `@${author?.username} (#${channel.name}, ${server?.name})`;
break;
default:
title = msg.channel;
break;
}
let image;
if (msg.attachments) {
let imageAttachment = msg.attachments.find(
(x) => x.metadata.type === "Image",
);
if (imageAttachment) {
image = client.generateFileURL(imageAttachment, {
max_side: 720,
});
}
}
let image;
if (msg.attachments) {
let imageAttachment = msg.attachments.find(
(x) => x.metadata.type === "Image",
);
if (imageAttachment) {
image = client.generateFileURL(imageAttachment, {
max_side: 720,
});
}
}
let body, icon;
if (typeof msg.content === "string") {
body = client.markdownToText(msg.content);
icon = client.users.getAvatarURL(msg.author, { max_side: 256 });
} else {
let users = client.users;
switch (msg.content.type) {
case "user_added":
case "user_remove":
body = translate(
`app.main.channel.system.${
msg.content.type === "user_added"
? "added_by"
: "removed_by"
}`,
{
user: users.get(msg.content.id)?.username,
other_user: users.get(msg.content.by)?.username,
},
);
icon = client.users.getAvatarURL(msg.content.id, {
max_side: 256,
});
break;
case "user_joined":
case "user_left":
case "user_kicked":
case "user_banned":
body = translate(
`app.main.channel.system.${msg.content.type}`,
{ user: users.get(msg.content.id)?.username },
);
icon = client.users.getAvatarURL(msg.content.id, {
max_side: 256,
});
break;
case "channel_renamed":
body = translate(
`app.main.channel.system.channel_renamed`,
{
user: users.get(msg.content.by)?.username,
name: msg.content.name,
},
);
icon = client.users.getAvatarURL(msg.content.by, {
max_side: 256,
});
break;
case "channel_description_changed":
case "channel_icon_changed":
body = translate(
`app.main.channel.system.${msg.content.type}`,
{ user: users.get(msg.content.by)?.username },
);
icon = client.users.getAvatarURL(msg.content.by, {
max_side: 256,
});
break;
}
}
let body, icon;
if (typeof msg.content === "string") {
body = client.markdownToText(msg.content);
icon = client.users.getAvatarURL(msg.author, { max_side: 256 });
} else {
let users = client.users;
switch (msg.content.type) {
case "user_added":
case "user_remove":
body = translate(
`app.main.channel.system.${
msg.content.type === "user_added"
? "added_by"
: "removed_by"
}`,
{
user: users.get(msg.content.id)?.username,
other_user: users.get(msg.content.by)?.username,
},
);
icon = client.users.getAvatarURL(msg.content.id, {
max_side: 256,
});
break;
case "user_joined":
case "user_left":
case "user_kicked":
case "user_banned":
body = translate(
`app.main.channel.system.${msg.content.type}`,
{ user: users.get(msg.content.id)?.username },
);
icon = client.users.getAvatarURL(msg.content.id, {
max_side: 256,
});
break;
case "channel_renamed":
body = translate(
`app.main.channel.system.channel_renamed`,
{
user: users.get(msg.content.by)?.username,
name: msg.content.name,
},
);
icon = client.users.getAvatarURL(msg.content.by, {
max_side: 256,
});
break;
case "channel_description_changed":
case "channel_icon_changed":
body = translate(
`app.main.channel.system.${msg.content.type}`,
{ user: users.get(msg.content.by)?.username },
);
icon = client.users.getAvatarURL(msg.content.by, {
max_side: 256,
});
break;
}
}
let notif = await createNotification(title, {
icon,
image,
body,
timestamp: decodeTime(msg._id),
tag: msg.channel,
badge: "/assets/icons/android-chrome-512x512.png",
silent: true,
});
let notif = await createNotification(title, {
icon,
image,
body,
timestamp: decodeTime(msg._id),
tag: msg.channel,
badge: "/assets/icons/android-chrome-512x512.png",
silent: true,
});
if (notif) {
notif.addEventListener("click", () => {
window.focus();
const id = msg.channel;
if (id !== channel_id) {
let channel = client.channels.get(id);
if (channel) {
if (channel.channel_type === "TextChannel") {
history.push(
`/server/${channel.server}/channel/${id}`,
);
} else {
history.push(`/channel/${id}`);
}
}
}
});
if (notif) {
notif.addEventListener("click", () => {
window.focus();
const id = msg.channel;
if (id !== channel_id) {
let channel = client.channels.get(id);
if (channel) {
if (channel.channel_type === "TextChannel") {
history.push(
`/server/${channel.server}/channel/${id}`,
);
} else {
history.push(`/channel/${id}`);
}
}
}
});
notifications[msg.channel] = notif;
notif.addEventListener(
"close",
() => delete notifications[msg.channel],
);
}
}
notifications[msg.channel] = notif;
notif.addEventListener(
"close",
() => delete notifications[msg.channel],
);
}
}
async function relationship(user: User, property: string) {
if (client.user?.status?.presence === Users.Presence.Busy) return;
if (property !== "relationship") return;
if (!showNotification) return;
async function relationship(user: User, property: string) {
if (client.user?.status?.presence === Users.Presence.Busy) return;
if (property !== "relationship") return;
if (!showNotification) return;
let event;
switch (user.relationship) {
case Users.Relationship.Incoming:
event = translate("notifications.sent_request", {
person: user.username,
});
break;
case Users.Relationship.Friend:
event = translate("notifications.now_friends", {
person: user.username,
});
break;
default:
return;
}
let event;
switch (user.relationship) {
case Users.Relationship.Incoming:
event = translate("notifications.sent_request", {
person: user.username,
});
break;
case Users.Relationship.Friend:
event = translate("notifications.now_friends", {
person: user.username,
});
break;
default:
return;
}
let notif = await createNotification(event, {
icon: client.users.getAvatarURL(user._id, { max_side: 256 }),
badge: "/assets/icons/android-chrome-512x512.png",
timestamp: +new Date(),
});
let notif = await createNotification(event, {
icon: client.users.getAvatarURL(user._id, { max_side: 256 }),
badge: "/assets/icons/android-chrome-512x512.png",
timestamp: +new Date(),
});
notif?.addEventListener("click", () => {
history.push(`/friends`);
});
}
notif?.addEventListener("click", () => {
history.push(`/friends`);
});
}
useEffect(() => {
client.addListener("message", message);
client.users.addListener("mutation", relationship);
useEffect(() => {
client.addListener("message", message);
client.users.addListener("mutation", relationship);
return () => {
client.removeListener("message", message);
client.users.removeListener("mutation", relationship);
};
}, [client, playSound, guild_id, channel_id, showNotification, notifs]);
return () => {
client.removeListener("message", message);
client.users.removeListener("mutation", relationship);
};
}, [client, playSound, guild_id, channel_id, showNotification, notifs]);
useEffect(() => {
function visChange() {
if (document.visibilityState === "visible") {
if (notifications[channel_id]) {
notifications[channel_id].close();
}
}
}
useEffect(() => {
function visChange() {
if (document.visibilityState === "visible") {
if (notifications[channel_id]) {
notifications[channel_id].close();
}
}
}
visChange();
visChange();
document.addEventListener("visibilitychange", visChange);
return () =>
document.removeEventListener("visibilitychange", visChange);
}, [guild_id, channel_id]);
document.addEventListener("visibilitychange", visChange);
return () =>
document.removeEventListener("visibilitychange", visChange);
}, [guild_id, channel_id]);
return null;
return null;
}
const NotifierComponent = connectState(
Notifier,
(state) => {
return {
options: state.settings.notification,
notifs: state.notifications,
};
},
true,
Notifier,
(state) => {
return {
options: state.settings.notification,
notifs: state.notifications,
};
},
true,
);
export default function NotificationsComponent() {
return (
<Switch>
<Route path="/server/:server/channel/:channel">
<NotifierComponent />
</Route>
<Route path="/channel/:channel">
<NotifierComponent />
</Route>
<Route path="/">
<NotifierComponent />
</Route>
</Switch>
);
return (
<Switch>
<Route path="/server/:server/channel/:channel">
<NotifierComponent />
</Route>
<Route path="/channel/:channel">
<NotifierComponent />
</Route>
<Route path="/">
<NotifierComponent />
</Route>
</Switch>
);
}

View file

@ -10,38 +10,38 @@ import { Children } from "../../types/Preact";
import { ClientStatus, StatusContext } from "./RevoltClient";
interface Props {
children: Children;
children: Children;
}
const Base = styled.div`
gap: 16px;
padding: 1em;
display: flex;
user-select: none;
align-items: center;
flex-direction: row;
justify-content: center;
color: var(--tertiary-foreground);
background: var(--secondary-header);
gap: 16px;
padding: 1em;
display: flex;
user-select: none;
align-items: center;
flex-direction: row;
justify-content: center;
color: var(--tertiary-foreground);
background: var(--secondary-header);
> div {
font-size: 18px;
}
> div {
font-size: 18px;
}
`;
export default function RequiresOnline(props: Props) {
const status = useContext(StatusContext);
const status = useContext(StatusContext);
if (status === ClientStatus.CONNECTING) return <Preloader type="ring" />;
if (status !== ClientStatus.ONLINE && status !== ClientStatus.READY)
return (
<Base>
<WifiOff size={16} />
<div>
<Text id="app.special.requires_online" />
</div>
</Base>
);
if (status === ClientStatus.CONNECTING) return <Preloader type="ring" />;
if (status !== ClientStatus.ONLINE && status !== ClientStatus.READY)
return (
<Base>
<WifiOff size={16} />
<div>
<Text id="app.special.requires_online" />
</div>
</Base>
);
return <>{props.children}</>;
return <>{props.children}</>;
}

View file

@ -20,23 +20,23 @@ import { registerEvents, setReconnectDisallowed } from "./events";
import { takeError } from "./util";
export enum ClientStatus {
INIT,
LOADING,
READY,
OFFLINE,
DISCONNECTED,
CONNECTING,
RECONNECTING,
ONLINE,
INIT,
LOADING,
READY,
OFFLINE,
DISCONNECTED,
CONNECTING,
RECONNECTING,
ONLINE,
}
export interface ClientOperations {
login: (data: Route<"POST", "/auth/login">["data"]) => Promise<void>;
logout: (shouldRequest?: boolean) => Promise<void>;
loggedIn: () => boolean;
ready: () => boolean;
login: (data: Route<"POST", "/auth/login">["data"]) => Promise<void>;
logout: (shouldRequest?: boolean) => Promise<void>;
loggedIn: () => boolean;
ready: () => boolean;
openDM: (user_id: string) => Promise<string>;
openDM: (user_id: string) => Promise<string>;
}
// By the time they are used, they should all be initialized.
@ -47,195 +47,195 @@ export const StatusContext = createContext<ClientStatus>(null!);
export const OperationsContext = createContext<ClientOperations>(null!);
type Props = {
auth: AuthState;
children: Children;
auth: AuthState;
children: Children;
};
function Context({ auth, children }: Props) {
const history = useHistory();
const { openScreen } = useIntermediate();
const [status, setStatus] = useState(ClientStatus.INIT);
const [client, setClient] = useState<Client>(
undefined as unknown as Client,
);
const history = useHistory();
const { openScreen } = useIntermediate();
const [status, setStatus] = useState(ClientStatus.INIT);
const [client, setClient] = useState<Client>(
undefined as unknown as Client,
);
useEffect(() => {
(async () => {
let db;
try {
// Match sw.ts#L23
db = await openDB("state", 3, {
upgrade(db) {
for (let store of [
"channels",
"servers",
"users",
"members",
]) {
db.createObjectStore(store, {
keyPath: "_id",
});
}
},
});
} catch (err) {
console.error(
"Failed to open IndexedDB store, continuing without.",
);
}
useEffect(() => {
(async () => {
let db;
try {
// Match sw.ts#L23
db = await openDB("state", 3, {
upgrade(db) {
for (let store of [
"channels",
"servers",
"users",
"members",
]) {
db.createObjectStore(store, {
keyPath: "_id",
});
}
},
});
} catch (err) {
console.error(
"Failed to open IndexedDB store, continuing without.",
);
}
const client = new Client({
autoReconnect: false,
apiURL: import.meta.env.VITE_API_URL,
debug: import.meta.env.DEV,
db,
});
const client = new Client({
autoReconnect: false,
apiURL: import.meta.env.VITE_API_URL,
debug: import.meta.env.DEV,
db,
});
setClient(client);
SingletonMessageRenderer.subscribe(client);
setStatus(ClientStatus.LOADING);
})();
}, []);
setClient(client);
SingletonMessageRenderer.subscribe(client);
setStatus(ClientStatus.LOADING);
})();
}, []);
if (status === ClientStatus.INIT) return null;
if (status === ClientStatus.INIT) return null;
const operations: ClientOperations = useMemo(() => {
return {
login: async (data) => {
setReconnectDisallowed(true);
const operations: ClientOperations = useMemo(() => {
return {
login: async (data) => {
setReconnectDisallowed(true);
try {
const onboarding = await client.login(data);
setReconnectDisallowed(false);
const login = () =>
dispatch({
type: "LOGIN",
session: client.session!, // This [null assertion] is ok, we should have a session by now. - insert's words
});
try {
const onboarding = await client.login(data);
setReconnectDisallowed(false);
const login = () =>
dispatch({
type: "LOGIN",
session: client.session!, // This [null assertion] is ok, we should have a session by now. - insert's words
});
if (onboarding) {
openScreen({
id: "onboarding",
callback: (username: string) =>
onboarding(username, true).then(login),
});
} else {
login();
}
} catch (err) {
setReconnectDisallowed(false);
throw err;
}
},
logout: async (shouldRequest) => {
dispatch({ type: "LOGOUT" });
if (onboarding) {
openScreen({
id: "onboarding",
callback: (username: string) =>
onboarding(username, true).then(login),
});
} else {
login();
}
} catch (err) {
setReconnectDisallowed(false);
throw err;
}
},
logout: async (shouldRequest) => {
dispatch({ type: "LOGOUT" });
client.reset();
dispatch({ type: "RESET" });
client.reset();
dispatch({ type: "RESET" });
openScreen({ id: "none" });
setStatus(ClientStatus.READY);
openScreen({ id: "none" });
setStatus(ClientStatus.READY);
client.websocket.disconnect();
client.websocket.disconnect();
if (shouldRequest) {
try {
await client.logout();
} catch (err) {
console.error(err);
}
}
},
loggedIn: () => typeof auth.active !== "undefined",
ready: () =>
operations.loggedIn() && typeof client.user !== "undefined",
openDM: async (user_id: string) => {
let channel = await client.users.openDM(user_id);
history.push(`/channel/${channel!._id}`);
return channel!._id;
},
};
}, [client, auth.active]);
if (shouldRequest) {
try {
await client.logout();
} catch (err) {
console.error(err);
}
}
},
loggedIn: () => typeof auth.active !== "undefined",
ready: () =>
operations.loggedIn() && typeof client.user !== "undefined",
openDM: async (user_id: string) => {
let channel = await client.users.openDM(user_id);
history.push(`/channel/${channel!._id}`);
return channel!._id;
},
};
}, [client, auth.active]);
useEffect(
() => registerEvents({ operations }, setStatus, client),
[client],
);
useEffect(
() => registerEvents({ operations }, setStatus, client),
[client],
);
useEffect(() => {
(async () => {
if (client.db) {
await client.restore();
}
useEffect(() => {
(async () => {
if (client.db) {
await client.restore();
}
if (auth.active) {
dispatch({ type: "QUEUE_FAIL_ALL" });
if (auth.active) {
dispatch({ type: "QUEUE_FAIL_ALL" });
const active = auth.accounts[auth.active];
client.user = client.users.get(active.session.user_id);
if (!navigator.onLine) {
return setStatus(ClientStatus.OFFLINE);
}
const active = auth.accounts[auth.active];
client.user = client.users.get(active.session.user_id);
if (!navigator.onLine) {
return setStatus(ClientStatus.OFFLINE);
}
if (operations.ready()) setStatus(ClientStatus.CONNECTING);
if (operations.ready()) setStatus(ClientStatus.CONNECTING);
if (navigator.onLine) {
await client
.fetchConfiguration()
.catch(() =>
console.error("Failed to connect to API server."),
);
}
if (navigator.onLine) {
await client
.fetchConfiguration()
.catch(() =>
console.error("Failed to connect to API server."),
);
}
try {
await client.fetchConfiguration();
const callback = await client.useExistingSession(
active.session,
);
try {
await client.fetchConfiguration();
const callback = await client.useExistingSession(
active.session,
);
if (callback) {
openScreen({ id: "onboarding", callback });
}
} catch (err) {
setStatus(ClientStatus.DISCONNECTED);
const error = takeError(err);
if (error === "Forbidden" || error === "Unauthorized") {
operations.logout(true);
openScreen({ id: "signed_out" });
} else {
openScreen({ id: "error", error });
}
}
} else {
try {
await client.fetchConfiguration();
} catch (err) {
console.error("Failed to connect to API server.");
}
if (callback) {
openScreen({ id: "onboarding", callback });
}
} catch (err) {
setStatus(ClientStatus.DISCONNECTED);
const error = takeError(err);
if (error === "Forbidden" || error === "Unauthorized") {
operations.logout(true);
openScreen({ id: "signed_out" });
} else {
openScreen({ id: "error", error });
}
}
} else {
try {
await client.fetchConfiguration();
} catch (err) {
console.error("Failed to connect to API server.");
}
setStatus(ClientStatus.READY);
}
})();
}, []);
setStatus(ClientStatus.READY);
}
})();
}, []);
if (status === ClientStatus.LOADING) {
return <Preloader type="spinner" />;
}
if (status === ClientStatus.LOADING) {
return <Preloader type="spinner" />;
}
return (
<AppContext.Provider value={client}>
<StatusContext.Provider value={status}>
<OperationsContext.Provider value={operations}>
{children}
</OperationsContext.Provider>
</StatusContext.Provider>
</AppContext.Provider>
);
return (
<AppContext.Provider value={client}>
<StatusContext.Provider value={status}>
<OperationsContext.Provider value={operations}>
{children}
</OperationsContext.Provider>
</StatusContext.Provider>
</AppContext.Provider>
);
}
export default connectState<{ children: Children }>(Context, (state) => {
return {
auth: state.auth,
sync: state.sync,
};
return {
auth: state.auth,
sync: state.sync,
};
});

View file

@ -13,64 +13,64 @@ import { Typing } from "../../redux/reducers/typing";
import { AppContext } from "./RevoltClient";
type Props = {
messages: QueuedMessage[];
typing: Typing;
messages: QueuedMessage[];
typing: Typing;
};
function StateMonitor(props: Props) {
const client = useContext(AppContext);
const client = useContext(AppContext);
useEffect(() => {
dispatch({
type: "QUEUE_DROP_ALL",
});
}, []);
useEffect(() => {
dispatch({
type: "QUEUE_DROP_ALL",
});
}, []);
useEffect(() => {
function add(msg: Message) {
if (!msg.nonce) return;
if (!props.messages.find((x) => x.id === msg.nonce)) return;
useEffect(() => {
function add(msg: Message) {
if (!msg.nonce) return;
if (!props.messages.find((x) => x.id === msg.nonce)) return;
dispatch({
type: "QUEUE_REMOVE",
nonce: msg.nonce,
});
}
dispatch({
type: "QUEUE_REMOVE",
nonce: msg.nonce,
});
}
client.addListener("message", add);
return () => client.removeListener("message", add);
}, [props.messages]);
client.addListener("message", add);
return () => client.removeListener("message", add);
}, [props.messages]);
useEffect(() => {
function removeOld() {
if (!props.typing) return;
for (let channel of Object.keys(props.typing)) {
let users = props.typing[channel];
useEffect(() => {
function removeOld() {
if (!props.typing) return;
for (let channel of Object.keys(props.typing)) {
let users = props.typing[channel];
for (let user of users) {
if (+new Date() > user.started + 5000) {
dispatch({
type: "TYPING_STOP",
channel,
user: user.id,
});
}
}
}
}
for (let user of users) {
if (+new Date() > user.started + 5000) {
dispatch({
type: "TYPING_STOP",
channel,
user: user.id,
});
}
}
}
}
removeOld();
removeOld();
let interval = setInterval(removeOld, 1000);
return () => clearInterval(interval);
}, [props.typing]);
let interval = setInterval(removeOld, 1000);
return () => clearInterval(interval);
}, [props.typing]);
return null;
return null;
}
export default connectState(StateMonitor, (state) => {
return {
messages: [...state.queue],
typing: state.typing,
};
return {
messages: [...state.queue],
typing: state.typing,
};
});

View file

@ -12,135 +12,135 @@ import { connectState } from "../../redux/connector";
import { Notifications } from "../../redux/reducers/notifications";
import { Settings } from "../../redux/reducers/settings";
import {
DEFAULT_ENABLED_SYNC,
SyncData,
SyncKeys,
SyncOptions,
DEFAULT_ENABLED_SYNC,
SyncData,
SyncKeys,
SyncOptions,
} from "../../redux/reducers/sync";
import { Language } from "../Locale";
import { AppContext, ClientStatus, StatusContext } from "./RevoltClient";
type Props = {
settings: Settings;
locale: Language;
sync: SyncOptions;
notifications: Notifications;
settings: Settings;
locale: Language;
sync: SyncOptions;
notifications: Notifications;
};
var lastValues: { [key in SyncKeys]?: any } = {};
export function mapSync(
packet: Sync.UserSettings,
revision?: Record<string, number>,
packet: Sync.UserSettings,
revision?: Record<string, number>,
) {
let update: { [key in SyncKeys]?: [number, SyncData[key]] } = {};
for (let key of Object.keys(packet)) {
let [timestamp, obj] = packet[key];
if (timestamp < (revision ?? {})[key] ?? 0) {
continue;
}
let update: { [key in SyncKeys]?: [number, SyncData[key]] } = {};
for (let key of Object.keys(packet)) {
let [timestamp, obj] = packet[key];
if (timestamp < (revision ?? {})[key] ?? 0) {
continue;
}
let object;
if (obj[0] === "{") {
object = JSON.parse(obj);
} else {
object = obj;
}
let object;
if (obj[0] === "{") {
object = JSON.parse(obj);
} else {
object = obj;
}
lastValues[key as SyncKeys] = object;
update[key as SyncKeys] = [timestamp, object];
}
lastValues[key as SyncKeys] = object;
update[key as SyncKeys] = [timestamp, object];
}
return update;
return update;
}
function SyncManager(props: Props) {
const client = useContext(AppContext);
const status = useContext(StatusContext);
const client = useContext(AppContext);
const status = useContext(StatusContext);
useEffect(() => {
if (status === ClientStatus.ONLINE) {
client
.syncFetchSettings(
DEFAULT_ENABLED_SYNC.filter(
(x) => !props.sync?.disabled?.includes(x),
),
)
.then((data) => {
dispatch({
type: "SYNC_UPDATE",
update: mapSync(data),
});
});
useEffect(() => {
if (status === ClientStatus.ONLINE) {
client
.syncFetchSettings(
DEFAULT_ENABLED_SYNC.filter(
(x) => !props.sync?.disabled?.includes(x),
),
)
.then((data) => {
dispatch({
type: "SYNC_UPDATE",
update: mapSync(data),
});
});
client
.syncFetchUnreads()
.then((unreads) => dispatch({ type: "UNREADS_SET", unreads }));
}
}, [status]);
client
.syncFetchUnreads()
.then((unreads) => dispatch({ type: "UNREADS_SET", unreads }));
}
}, [status]);
function syncChange(key: SyncKeys, data: any) {
let timestamp = +new Date();
dispatch({
type: "SYNC_SET_REVISION",
key,
timestamp,
});
function syncChange(key: SyncKeys, data: any) {
let timestamp = +new Date();
dispatch({
type: "SYNC_SET_REVISION",
key,
timestamp,
});
client.syncSetSettings(
{
[key]: data,
},
timestamp,
);
}
client.syncSetSettings(
{
[key]: data,
},
timestamp,
);
}
let disabled = props.sync.disabled ?? [];
for (let [key, object] of [
["appearance", props.settings.appearance],
["theme", props.settings.theme],
["locale", props.locale],
["notifications", props.notifications],
] as [SyncKeys, any][]) {
useEffect(() => {
if (disabled.indexOf(key) === -1) {
if (typeof lastValues[key] !== "undefined") {
if (!isEqual(lastValues[key], object)) {
syncChange(key, object);
}
}
}
let disabled = props.sync.disabled ?? [];
for (let [key, object] of [
["appearance", props.settings.appearance],
["theme", props.settings.theme],
["locale", props.locale],
["notifications", props.notifications],
] as [SyncKeys, any][]) {
useEffect(() => {
if (disabled.indexOf(key) === -1) {
if (typeof lastValues[key] !== "undefined") {
if (!isEqual(lastValues[key], object)) {
syncChange(key, object);
}
}
}
lastValues[key] = object;
}, [disabled, object]);
}
lastValues[key] = object;
}, [disabled, object]);
}
useEffect(() => {
function onPacket(packet: ClientboundNotification) {
if (packet.type === "UserSettingsUpdate") {
let update: { [key in SyncKeys]?: [number, SyncData[key]] } =
mapSync(packet.update, props.sync.revision);
useEffect(() => {
function onPacket(packet: ClientboundNotification) {
if (packet.type === "UserSettingsUpdate") {
let update: { [key in SyncKeys]?: [number, SyncData[key]] } =
mapSync(packet.update, props.sync.revision);
dispatch({
type: "SYNC_UPDATE",
update,
});
}
}
dispatch({
type: "SYNC_UPDATE",
update,
});
}
}
client.addListener("packet", onPacket);
return () => client.removeListener("packet", onPacket);
}, [disabled, props.sync]);
client.addListener("packet", onPacket);
return () => client.removeListener("packet", onPacket);
}, [disabled, props.sync]);
return null;
return null;
}
export default connectState(SyncManager, (state) => {
return {
settings: state.settings,
locale: state.locale,
sync: state.sync,
notifications: state.notifications,
};
return {
settings: state.settings,
locale: state.locale,
sync: state.sync,
notifications: state.notifications,
};
});

View file

@ -11,145 +11,145 @@ export var preventReconnect = false;
let preventUntil = 0;
export function setReconnectDisallowed(allowed: boolean) {
preventReconnect = allowed;
preventReconnect = allowed;
}
export function registerEvents(
{ operations }: { operations: ClientOperations },
setStatus: StateUpdater<ClientStatus>,
client: Client,
{ operations }: { operations: ClientOperations },
setStatus: StateUpdater<ClientStatus>,
client: Client,
) {
function attemptReconnect() {
if (preventReconnect) return;
function reconnect() {
preventUntil = +new Date() + 2000;
client.websocket.connect().catch((err) => console.error(err));
}
function attemptReconnect() {
if (preventReconnect) return;
function reconnect() {
preventUntil = +new Date() + 2000;
client.websocket.connect().catch((err) => console.error(err));
}
if (+new Date() > preventUntil) {
setTimeout(reconnect, 2000);
} else {
reconnect();
}
}
if (+new Date() > preventUntil) {
setTimeout(reconnect, 2000);
} else {
reconnect();
}
}
let listeners: Record<string, (...args: any[]) => void> = {
connecting: () =>
operations.ready() && setStatus(ClientStatus.CONNECTING),
let listeners: Record<string, (...args: any[]) => void> = {
connecting: () =>
operations.ready() && setStatus(ClientStatus.CONNECTING),
dropped: () => {
if (operations.ready()) {
setStatus(ClientStatus.DISCONNECTED);
attemptReconnect();
}
},
dropped: () => {
if (operations.ready()) {
setStatus(ClientStatus.DISCONNECTED);
attemptReconnect();
}
},
packet: (packet: ClientboundNotification) => {
switch (packet.type) {
case "ChannelStartTyping": {
if (packet.user === client.user?._id) return;
dispatch({
type: "TYPING_START",
channel: packet.id,
user: packet.user,
});
break;
}
case "ChannelStopTyping": {
if (packet.user === client.user?._id) return;
dispatch({
type: "TYPING_STOP",
channel: packet.id,
user: packet.user,
});
break;
}
case "ChannelAck": {
dispatch({
type: "UNREADS_MARK_READ",
channel: packet.id,
message: packet.message_id,
});
break;
}
}
},
packet: (packet: ClientboundNotification) => {
switch (packet.type) {
case "ChannelStartTyping": {
if (packet.user === client.user?._id) return;
dispatch({
type: "TYPING_START",
channel: packet.id,
user: packet.user,
});
break;
}
case "ChannelStopTyping": {
if (packet.user === client.user?._id) return;
dispatch({
type: "TYPING_STOP",
channel: packet.id,
user: packet.user,
});
break;
}
case "ChannelAck": {
dispatch({
type: "UNREADS_MARK_READ",
channel: packet.id,
message: packet.message_id,
});
break;
}
}
},
message: (message: Message) => {
if (message.mentions?.includes(client.user!._id)) {
dispatch({
type: "UNREADS_MENTION",
channel: message.channel,
message: message._id,
});
}
},
message: (message: Message) => {
if (message.mentions?.includes(client.user!._id)) {
dispatch({
type: "UNREADS_MENTION",
channel: message.channel,
message: message._id,
});
}
},
ready: () => setStatus(ClientStatus.ONLINE),
};
ready: () => setStatus(ClientStatus.ONLINE),
};
if (import.meta.env.DEV) {
listeners = new Proxy(listeners, {
get:
(target, listener, receiver) =>
(...args: unknown[]) => {
console.debug(`Calling ${listener.toString()} with`, args);
Reflect.get(target, listener)(...args);
},
});
}
if (import.meta.env.DEV) {
listeners = new Proxy(listeners, {
get:
(target, listener, receiver) =>
(...args: unknown[]) => {
console.debug(`Calling ${listener.toString()} with`, args);
Reflect.get(target, listener)(...args);
},
});
}
// TODO: clean this a bit and properly handle types
for (const listener in listeners) {
client.addListener(listener, listeners[listener]);
}
// TODO: clean this a bit and properly handle types
for (const listener in listeners) {
client.addListener(listener, listeners[listener]);
}
function logMutation(target: string, key: string) {
console.log("(o) Object mutated", target, "\nChanged:", key);
}
function logMutation(target: string, key: string) {
console.log("(o) Object mutated", target, "\nChanged:", key);
}
if (import.meta.env.DEV) {
client.users.addListener("mutation", logMutation);
client.servers.addListener("mutation", logMutation);
client.channels.addListener("mutation", logMutation);
client.servers.members.addListener("mutation", logMutation);
}
if (import.meta.env.DEV) {
client.users.addListener("mutation", logMutation);
client.servers.addListener("mutation", logMutation);
client.channels.addListener("mutation", logMutation);
client.servers.members.addListener("mutation", logMutation);
}
const online = () => {
if (operations.ready()) {
setStatus(ClientStatus.RECONNECTING);
setReconnectDisallowed(false);
attemptReconnect();
}
};
const online = () => {
if (operations.ready()) {
setStatus(ClientStatus.RECONNECTING);
setReconnectDisallowed(false);
attemptReconnect();
}
};
const offline = () => {
if (operations.ready()) {
setReconnectDisallowed(true);
client.websocket.disconnect();
setStatus(ClientStatus.OFFLINE);
}
};
const offline = () => {
if (operations.ready()) {
setReconnectDisallowed(true);
client.websocket.disconnect();
setStatus(ClientStatus.OFFLINE);
}
};
window.addEventListener("online", online);
window.addEventListener("offline", offline);
window.addEventListener("online", online);
window.addEventListener("offline", offline);
return () => {
for (const listener in listeners) {
client.removeListener(
listener,
listeners[listener as keyof typeof listeners],
);
}
return () => {
for (const listener in listeners) {
client.removeListener(
listener,
listeners[listener as keyof typeof listeners],
);
}
if (import.meta.env.DEV) {
client.users.removeListener("mutation", logMutation);
client.servers.removeListener("mutation", logMutation);
client.channels.removeListener("mutation", logMutation);
client.servers.members.removeListener("mutation", logMutation);
}
if (import.meta.env.DEV) {
client.users.removeListener("mutation", logMutation);
client.servers.removeListener("mutation", logMutation);
client.channels.removeListener("mutation", logMutation);
client.servers.members.removeListener("mutation", logMutation);
}
window.removeEventListener("online", online);
window.removeEventListener("offline", offline);
};
window.removeEventListener("online", online);
window.removeEventListener("offline", offline);
};
}

View file

@ -7,226 +7,226 @@ import { useCallback, useContext, useEffect, useState } from "preact/hooks";
import { AppContext } from "./RevoltClient";
export interface HookContext {
client: Client;
forceUpdate: () => void;
client: Client;
forceUpdate: () => void;
}
export function useForceUpdate(context?: HookContext): HookContext {
const client = useContext(AppContext);
if (context) return context;
const client = useContext(AppContext);
if (context) return context;
const H = useState(0);
var updateState: (_: number) => void;
if (Array.isArray(H)) {
let [, u] = H;
updateState = u;
} else {
console.warn("Failed to construct using useState.");
updateState = () => {};
}
const H = useState(0);
var updateState: (_: number) => void;
if (Array.isArray(H)) {
let [, u] = H;
updateState = u;
} else {
console.warn("Failed to construct using useState.");
updateState = () => {};
}
return { client, forceUpdate: () => updateState(Math.random()) };
return { client, forceUpdate: () => updateState(Math.random()) };
}
// TODO: utils.d.ts maybe?
type PickProperties<T, U> = Pick<
T,
{
[K in keyof T]: T[K] extends U ? K : never;
}[keyof T]
T,
{
[K in keyof T]: T[K] extends U ? K : never;
}[keyof T]
>;
// The keys in Client that are an object
// for some reason undefined keeps appearing despite there being no reason to so it's filtered out
type ClientCollectionKey = Exclude<
keyof PickProperties<Client, Collection<any>>,
undefined
keyof PickProperties<Client, Collection<any>>,
undefined
>;
function useObject(
type: ClientCollectionKey,
id?: string | string[],
context?: HookContext,
type: ClientCollectionKey,
id?: string | string[],
context?: HookContext,
) {
const ctx = useForceUpdate(context);
const ctx = useForceUpdate(context);
function update(target: any) {
if (
typeof id === "string"
? target === id
: Array.isArray(id)
? id.includes(target)
: true
) {
ctx.forceUpdate();
}
}
function update(target: any) {
if (
typeof id === "string"
? target === id
: Array.isArray(id)
? id.includes(target)
: true
) {
ctx.forceUpdate();
}
}
const map = ctx.client[type];
useEffect(() => {
map.addListener("update", update);
return () => map.removeListener("update", update);
}, [id]);
const map = ctx.client[type];
useEffect(() => {
map.addListener("update", update);
return () => map.removeListener("update", update);
}, [id]);
return typeof id === "string"
? map.get(id)
: Array.isArray(id)
? id.map((x) => map.get(x))
: map.toArray();
return typeof id === "string"
? map.get(id)
: Array.isArray(id)
? id.map((x) => map.get(x))
: map.toArray();
}
export function useUser(id?: string, context?: HookContext) {
if (typeof id === "undefined") return;
return useObject("users", id, context) as Readonly<Users.User> | undefined;
if (typeof id === "undefined") return;
return useObject("users", id, context) as Readonly<Users.User> | undefined;
}
export function useSelf(context?: HookContext) {
const ctx = useForceUpdate(context);
return useUser(ctx.client.user!._id, ctx);
const ctx = useForceUpdate(context);
return useUser(ctx.client.user!._id, ctx);
}
export function useUsers(ids?: string[], context?: HookContext) {
return useObject("users", ids, context) as (
| Readonly<Users.User>
| undefined
)[];
return useObject("users", ids, context) as (
| Readonly<Users.User>
| undefined
)[];
}
export function useChannel(id?: string, context?: HookContext) {
if (typeof id === "undefined") return;
return useObject("channels", id, context) as
| Readonly<Channels.Channel>
| undefined;
if (typeof id === "undefined") return;
return useObject("channels", id, context) as
| Readonly<Channels.Channel>
| undefined;
}
export function useChannels(ids?: string[], context?: HookContext) {
return useObject("channels", ids, context) as (
| Readonly<Channels.Channel>
| undefined
)[];
return useObject("channels", ids, context) as (
| Readonly<Channels.Channel>
| undefined
)[];
}
export function useServer(id?: string, context?: HookContext) {
if (typeof id === "undefined") return;
return useObject("servers", id, context) as
| Readonly<Servers.Server>
| undefined;
if (typeof id === "undefined") return;
return useObject("servers", id, context) as
| Readonly<Servers.Server>
| undefined;
}
export function useServers(ids?: string[], context?: HookContext) {
return useObject("servers", ids, context) as (
| Readonly<Servers.Server>
| undefined
)[];
return useObject("servers", ids, context) as (
| Readonly<Servers.Server>
| undefined
)[];
}
export function useDMs(context?: HookContext) {
const ctx = useForceUpdate(context);
const ctx = useForceUpdate(context);
function mutation(target: string) {
let channel = ctx.client.channels.get(target);
if (channel) {
if (
channel.channel_type === "DirectMessage" ||
channel.channel_type === "Group"
) {
ctx.forceUpdate();
}
}
}
function mutation(target: string) {
let channel = ctx.client.channels.get(target);
if (channel) {
if (
channel.channel_type === "DirectMessage" ||
channel.channel_type === "Group"
) {
ctx.forceUpdate();
}
}
}
const map = ctx.client.channels;
useEffect(() => {
map.addListener("update", mutation);
return () => map.removeListener("update", mutation);
}, []);
const map = ctx.client.channels;
useEffect(() => {
map.addListener("update", mutation);
return () => map.removeListener("update", mutation);
}, []);
return map
.toArray()
.filter(
(x) =>
x.channel_type === "DirectMessage" ||
x.channel_type === "Group" ||
x.channel_type === "SavedMessages",
) as (
| Channels.GroupChannel
| Channels.DirectMessageChannel
| Channels.SavedMessagesChannel
)[];
return map
.toArray()
.filter(
(x) =>
x.channel_type === "DirectMessage" ||
x.channel_type === "Group" ||
x.channel_type === "SavedMessages",
) as (
| Channels.GroupChannel
| Channels.DirectMessageChannel
| Channels.SavedMessagesChannel
)[];
}
export function useUserPermission(id: string, context?: HookContext) {
const ctx = useForceUpdate(context);
const ctx = useForceUpdate(context);
const mutation = (target: string) => target === id && ctx.forceUpdate();
useEffect(() => {
ctx.client.users.addListener("update", mutation);
return () => ctx.client.users.removeListener("update", mutation);
}, [id]);
const mutation = (target: string) => target === id && ctx.forceUpdate();
useEffect(() => {
ctx.client.users.addListener("update", mutation);
return () => ctx.client.users.removeListener("update", mutation);
}, [id]);
let calculator = new PermissionCalculator(ctx.client);
return calculator.forUser(id);
let calculator = new PermissionCalculator(ctx.client);
return calculator.forUser(id);
}
export function useChannelPermission(id: string, context?: HookContext) {
const ctx = useForceUpdate(context);
const ctx = useForceUpdate(context);
const channel = ctx.client.channels.get(id);
const server =
channel &&
(channel.channel_type === "TextChannel" ||
channel.channel_type === "VoiceChannel")
? channel.server
: undefined;
const channel = ctx.client.channels.get(id);
const server =
channel &&
(channel.channel_type === "TextChannel" ||
channel.channel_type === "VoiceChannel")
? channel.server
: undefined;
const mutation = (target: string) => target === id && ctx.forceUpdate();
const mutationServer = (target: string) =>
target === server && ctx.forceUpdate();
const mutationMember = (target: string) =>
target.substr(26) === ctx.client.user!._id && ctx.forceUpdate();
const mutation = (target: string) => target === id && ctx.forceUpdate();
const mutationServer = (target: string) =>
target === server && ctx.forceUpdate();
const mutationMember = (target: string) =>
target.substr(26) === ctx.client.user!._id && ctx.forceUpdate();
useEffect(() => {
ctx.client.channels.addListener("update", mutation);
useEffect(() => {
ctx.client.channels.addListener("update", mutation);
if (server) {
ctx.client.servers.addListener("update", mutationServer);
ctx.client.servers.members.addListener("update", mutationMember);
}
if (server) {
ctx.client.servers.addListener("update", mutationServer);
ctx.client.servers.members.addListener("update", mutationMember);
}
return () => {
ctx.client.channels.removeListener("update", mutation);
return () => {
ctx.client.channels.removeListener("update", mutation);
if (server) {
ctx.client.servers.removeListener("update", mutationServer);
ctx.client.servers.members.removeListener(
"update",
mutationMember,
);
}
};
}, [id]);
if (server) {
ctx.client.servers.removeListener("update", mutationServer);
ctx.client.servers.members.removeListener(
"update",
mutationMember,
);
}
};
}, [id]);
let calculator = new PermissionCalculator(ctx.client);
return calculator.forChannel(id);
let calculator = new PermissionCalculator(ctx.client);
return calculator.forChannel(id);
}
export function useServerPermission(id: string, context?: HookContext) {
const ctx = useForceUpdate(context);
const ctx = useForceUpdate(context);
const mutation = (target: string) => target === id && ctx.forceUpdate();
const mutationMember = (target: string) =>
target.substr(26) === ctx.client.user!._id && ctx.forceUpdate();
const mutation = (target: string) => target === id && ctx.forceUpdate();
const mutationMember = (target: string) =>
target.substr(26) === ctx.client.user!._id && ctx.forceUpdate();
useEffect(() => {
ctx.client.servers.addListener("update", mutation);
ctx.client.servers.members.addListener("update", mutationMember);
useEffect(() => {
ctx.client.servers.addListener("update", mutation);
ctx.client.servers.members.addListener("update", mutationMember);
return () => {
ctx.client.servers.removeListener("update", mutation);
ctx.client.servers.members.removeListener("update", mutationMember);
};
}, [id]);
return () => {
ctx.client.servers.removeListener("update", mutation);
ctx.client.servers.members.removeListener("update", mutationMember);
};
}, [id]);
let calculator = new PermissionCalculator(ctx.client);
return calculator.forServer(id);
let calculator = new PermissionCalculator(ctx.client);
return calculator.forServer(id);
}

View file

@ -6,52 +6,52 @@ import { Text } from "preact-i18n";
import { Children } from "../../types/Preact";
export function takeError(error: any): string {
const type = error?.response?.data?.type;
let id = type;
if (!type) {
if (error?.response?.status === 403) {
return "Unauthorized";
} else if (error && !!error.isAxiosError && !error.response) {
return "NetworkError";
}
const type = error?.response?.data?.type;
let id = type;
if (!type) {
if (error?.response?.status === 403) {
return "Unauthorized";
} else if (error && !!error.isAxiosError && !error.response) {
return "NetworkError";
}
console.error(error);
return "UnknownError";
}
console.error(error);
return "UnknownError";
}
return id;
return id;
}
export function getChannelName(
client: Client,
channel: Channel,
prefixType?: boolean,
client: Client,
channel: Channel,
prefixType?: boolean,
): Children {
if (channel.channel_type === "SavedMessages")
return <Text id="app.navigation.tabs.saved" />;
if (channel.channel_type === "SavedMessages")
return <Text id="app.navigation.tabs.saved" />;
if (channel.channel_type === "DirectMessage") {
let uid = client.channels.getRecipient(channel._id);
return (
<>
{prefixType && "@"}
{client.users.get(uid)?.username}
</>
);
}
if (channel.channel_type === "DirectMessage") {
let uid = client.channels.getRecipient(channel._id);
return (
<>
{prefixType && "@"}
{client.users.get(uid)?.username}
</>
);
}
if (channel.channel_type === "TextChannel" && prefixType) {
return <>#{channel.name}</>;
}
if (channel.channel_type === "TextChannel" && prefixType) {
return <>#{channel.name}</>;
}
return <>{channel.name}</>;
return <>{channel.name}</>;
}
export type MessageObject = Omit<Message, "edited"> & { edited?: string };
export function mapMessage(message: Partial<Message>) {
const { edited, ...msg } = message;
return {
...msg,
edited: edited?.$date,
} as MessageObject;
const { edited, ...msg } = message;
return {
...msg,
edited: edited?.$date,
} as MessageObject;
}

4
src/env.d.ts vendored
View file

@ -1,4 +1,4 @@
interface ImportMetaEnv {
VITE_API_URL: string;
VITE_THEMES_URL: string;
VITE_API_URL: string;
VITE_THEMES_URL: string;
}

View file

@ -1,16 +1,16 @@
import { Link, LinkProps } from "react-router-dom";
type Props = LinkProps &
JSX.HTMLAttributes<HTMLAnchorElement> & {
active: boolean;
};
JSX.HTMLAttributes<HTMLAnchorElement> & {
active: boolean;
};
export default function ConditionalLink(props: Props) {
const { active, ...linkProps } = props;
const { active, ...linkProps } = props;
if (active) {
return <a>{props.children}</a>;
} else {
return <Link {...linkProps} />;
}
if (active) {
return <a>{props.children}</a>;
} else {
return <Link {...linkProps} />;
}
}

File diff suppressed because it is too large Load diff

View file

@ -3,20 +3,20 @@ import { useState } from "preact/hooks";
const counts: { [key: string]: number } = {};
export default function PaintCounter({
small,
always,
small,
always,
}: {
small?: boolean;
always?: boolean;
small?: boolean;
always?: boolean;
}) {
if (import.meta.env.PROD && !always) return null;
if (import.meta.env.PROD && !always) return null;
const [uniqueId] = useState("" + Math.random());
const count = counts[uniqueId] ?? 0;
counts[uniqueId] = count + 1;
return (
<div style={{ textAlign: "center", fontSize: "0.8em" }}>
{small ? <>P: {count + 1}</> : <>Painted {count + 1} time(s).</>}
</div>
);
const [uniqueId] = useState("" + Math.random());
const count = counts[uniqueId] ?? 0;
counts[uniqueId] = count + 1;
return (
<div style={{ textAlign: "center", fontSize: "0.8em" }}>
{small ? <>P: {count + 1}</> : <>Painted {count + 1} time(s).</>}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show more