mirror of
https://github.com/revoltchat/revite.git
synced 2024-12-24 06:32:08 -05:00
Run prettier on all files.
This commit is contained in:
parent
56058c1e75
commit
7bd33d8d34
181 changed files with 18084 additions and 13521 deletions
|
@ -3,7 +3,7 @@ module.exports = {
|
||||||
"useTabs": true,
|
"useTabs": true,
|
||||||
"trailingComma": "all",
|
"trailingComma": "all",
|
||||||
"jsxBracketSameLine": true,
|
"jsxBracketSameLine": true,
|
||||||
"importOrder": ["/(lib)", "/(redux)", "/(context)", "/(ui|common)|.svg$", "^[./]"],
|
"importOrder": ["preact|classnames|.scss$", "/(lib)", "/(redux)", "/(context)", "/(ui|common)|.svg$", "^[./]"],
|
||||||
"importOrderSeparation": true,
|
"importOrderSeparation": true,
|
||||||
}
|
}
|
||||||
|
|
1851
src/assets/emojis.ts
1851
src/assets/emojis.ts
File diff suppressed because one or more lines are too long
|
@ -1,24 +1,29 @@
|
||||||
import message from './message.mp3';
|
import call_join from "./call_join.mp3";
|
||||||
import outbound from './outbound.mp3';
|
import call_leave from "./call_leave.mp3";
|
||||||
import call_join from './call_join.mp3';
|
import message from "./message.mp3";
|
||||||
import call_leave from './call_leave.mp3';
|
import outbound from "./outbound.mp3";
|
||||||
|
|
||||||
const SoundMap: { [key in Sounds]: string } = {
|
const SoundMap: { [key in Sounds]: string } = {
|
||||||
message,
|
message,
|
||||||
outbound,
|
outbound,
|
||||||
call_join,
|
call_join,
|
||||||
call_leave
|
call_leave,
|
||||||
}
|
};
|
||||||
|
|
||||||
export type Sounds = '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' ];
|
export const SOUNDS_ARRAY: Sounds[] = [
|
||||||
|
"message",
|
||||||
|
"outbound",
|
||||||
|
"call_join",
|
||||||
|
"call_leave",
|
||||||
|
];
|
||||||
|
|
||||||
export function playSound(sound: Sounds) {
|
export function playSound(sound: Sounds) {
|
||||||
let file = SoundMap[sound];
|
let file = SoundMap[sound];
|
||||||
let el = new Audio(file);
|
let el = new Audio(file);
|
||||||
try {
|
try {
|
||||||
el.play();
|
el.play();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to play audio file', file, err);
|
console.error("Failed to play audio file", file, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,449 +1,475 @@
|
||||||
import { StateUpdater, useContext, useState } from "preact/hooks";
|
|
||||||
import { AppContext } from "../../context/revoltjs/RevoltClient";
|
|
||||||
import { Channels } from "revolt.js/dist/api/objects";
|
|
||||||
import { emojiDictionary } from "../../assets/emojis";
|
|
||||||
import { SYSTEM_USER_ID, User } from "revolt.js";
|
import { SYSTEM_USER_ID, User } from "revolt.js";
|
||||||
import UserIcon from "./user/UserIcon";
|
import { Channels } from "revolt.js/dist/api/objects";
|
||||||
import styled, { css } from "styled-components";
|
import styled, { css } from "styled-components";
|
||||||
import Emoji from "./Emoji";
|
|
||||||
|
import { StateUpdater, useContext, useState } from "preact/hooks";
|
||||||
|
|
||||||
|
import { AppContext } from "../../context/revoltjs/RevoltClient";
|
||||||
|
|
||||||
|
import { emojiDictionary } from "../../assets/emojis";
|
||||||
import ChannelIcon from "./ChannelIcon";
|
import ChannelIcon from "./ChannelIcon";
|
||||||
|
import Emoji from "./Emoji";
|
||||||
|
import UserIcon from "./user/UserIcon";
|
||||||
|
|
||||||
export type AutoCompleteState =
|
export type AutoCompleteState =
|
||||||
| { type: "none" }
|
| { type: "none" }
|
||||||
| ({ selected: number; within: boolean; } & (
|
| ({ selected: number; within: boolean } & (
|
||||||
{
|
| {
|
||||||
type: "emoji";
|
type: "emoji";
|
||||||
matches: string[];
|
matches: string[];
|
||||||
} |
|
}
|
||||||
{
|
| {
|
||||||
type: "user";
|
type: "user";
|
||||||
matches: User[];
|
matches: User[];
|
||||||
} |
|
}
|
||||||
{
|
| {
|
||||||
type: "channel";
|
type: "channel";
|
||||||
matches: Channels.TextChannel[];
|
matches: Channels.TextChannel[];
|
||||||
}
|
}
|
||||||
));
|
));
|
||||||
|
|
||||||
export type SearchClues = {
|
export type SearchClues = {
|
||||||
users?: { type: 'channel', id: string } | { type: 'all' },
|
users?: { type: "channel"; id: string } | { type: "all" };
|
||||||
channels?: { server: string }
|
channels?: { server: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AutoCompleteProps = {
|
export type AutoCompleteProps = {
|
||||||
detached?: boolean,
|
detached?: boolean;
|
||||||
state: AutoCompleteState,
|
state: AutoCompleteState;
|
||||||
setState: StateUpdater<AutoCompleteState>,
|
setState: StateUpdater<AutoCompleteState>;
|
||||||
|
|
||||||
onKeyUp: (ev: KeyboardEvent) => void,
|
onKeyUp: (ev: KeyboardEvent) => void;
|
||||||
onKeyDown: (ev: KeyboardEvent) => boolean,
|
onKeyDown: (ev: KeyboardEvent) => boolean;
|
||||||
onChange: (ev: JSX.TargetedEvent<HTMLTextAreaElement, Event>) => void,
|
onChange: (ev: JSX.TargetedEvent<HTMLTextAreaElement, Event>) => void;
|
||||||
onClick: JSX.MouseEventHandler<HTMLButtonElement>,
|
onClick: JSX.MouseEventHandler<HTMLButtonElement>;
|
||||||
onFocus: JSX.FocusEventHandler<HTMLTextAreaElement>,
|
onFocus: JSX.FocusEventHandler<HTMLTextAreaElement>;
|
||||||
onBlur: JSX.FocusEventHandler<HTMLTextAreaElement>
|
onBlur: JSX.FocusEventHandler<HTMLTextAreaElement>;
|
||||||
}
|
};
|
||||||
|
|
||||||
export function useAutoComplete(setValue: (v?: string) => void, searchClues?: SearchClues): AutoCompleteProps {
|
export function useAutoComplete(
|
||||||
const [state, setState] = useState<AutoCompleteState>({ type: 'none' });
|
setValue: (v?: string) => void,
|
||||||
const [focused, setFocused] = useState(false);
|
searchClues?: SearchClues,
|
||||||
const client = useContext(AppContext);
|
): AutoCompleteProps {
|
||||||
|
const [state, setState] = useState<AutoCompleteState>({ type: "none" });
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
const client = useContext(AppContext);
|
||||||
|
|
||||||
function findSearchString(
|
function findSearchString(
|
||||||
el: HTMLTextAreaElement
|
el: HTMLTextAreaElement,
|
||||||
): ["emoji" | "user" | "channel", string, number] | undefined {
|
): ["emoji" | "user" | "channel", string, number] | undefined {
|
||||||
if (el.selectionStart === el.selectionEnd) {
|
if (el.selectionStart === el.selectionEnd) {
|
||||||
let cursor = el.selectionStart;
|
let cursor = el.selectionStart;
|
||||||
let content = el.value.slice(0, cursor);
|
let content = el.value.slice(0, cursor);
|
||||||
|
|
||||||
let valid = /\w/;
|
let valid = /\w/;
|
||||||
|
|
||||||
let j = content.length - 1;
|
let j = content.length - 1;
|
||||||
if (content[j] === '@') {
|
if (content[j] === "@") {
|
||||||
return [
|
return ["user", "", j];
|
||||||
"user",
|
} else if (content[j] === "#") {
|
||||||
"",
|
return ["channel", "", j];
|
||||||
j
|
}
|
||||||
];
|
|
||||||
} else if (content[j] === '#') {
|
|
||||||
return [
|
|
||||||
"channel",
|
|
||||||
"",
|
|
||||||
j
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
while (j >= 0 && valid.test(content[j])) {
|
while (j >= 0 && valid.test(content[j])) {
|
||||||
j--;
|
j--;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (j === -1) return;
|
if (j === -1) return;
|
||||||
let current = content[j];
|
let current = content[j];
|
||||||
|
|
||||||
if (current === ":" || current === "@" || current === "#") {
|
if (current === ":" || current === "@" || current === "#") {
|
||||||
let search = content.slice(j + 1, content.length);
|
let search = content.slice(j + 1, content.length);
|
||||||
if (search.length > 0) {
|
if (search.length > 0) {
|
||||||
return [
|
return [
|
||||||
current === "#" ? "channel" :
|
current === "#"
|
||||||
current === ":" ? "emoji" : "user",
|
? "channel"
|
||||||
search.toLowerCase(),
|
: current === ":"
|
||||||
j + 1
|
? "emoji"
|
||||||
];
|
: "user",
|
||||||
}
|
search.toLowerCase(),
|
||||||
}
|
j + 1,
|
||||||
}
|
];
|
||||||
}
|
}
|
||||||
|
}
|
||||||
function onChange(ev: JSX.TargetedEvent<HTMLTextAreaElement, Event>) {
|
}
|
||||||
const el = ev.currentTarget;
|
}
|
||||||
|
|
||||||
let result = findSearchString(el);
|
function onChange(ev: JSX.TargetedEvent<HTMLTextAreaElement, Event>) {
|
||||||
if (result) {
|
const el = ev.currentTarget;
|
||||||
let [type, search] = result;
|
|
||||||
const regex = new RegExp(search, 'i');
|
|
||||||
|
|
||||||
if (type === "emoji") {
|
let result = findSearchString(el);
|
||||||
// ! FIXME: we should convert it to a Binary Search Tree and use that
|
if (result) {
|
||||||
let matches = Object.keys(emojiDictionary)
|
let [type, search] = result;
|
||||||
.filter((emoji: string) => emoji.match(regex))
|
const regex = new RegExp(search, "i");
|
||||||
.splice(0, 5);
|
|
||||||
|
|
||||||
if (matches.length > 0) {
|
if (type === "emoji") {
|
||||||
let currentPosition =
|
// ! FIXME: we should convert it to a Binary Search Tree and use that
|
||||||
state.type !== "none"
|
let matches = Object.keys(emojiDictionary)
|
||||||
? state.selected
|
.filter((emoji: string) => emoji.match(regex))
|
||||||
: 0;
|
.splice(0, 5);
|
||||||
|
|
||||||
setState({
|
|
||||||
type: "emoji",
|
|
||||||
matches,
|
|
||||||
selected: Math.min(currentPosition, matches.length - 1),
|
|
||||||
within: false
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
if (matches.length > 0) {
|
||||||
}
|
let currentPosition =
|
||||||
}
|
state.type !== "none" ? state.selected : 0;
|
||||||
|
|
||||||
if (type === "user" && searchClues?.users) {
|
setState({
|
||||||
let users: User[] = [];
|
type: "emoji",
|
||||||
switch (searchClues.users.type) {
|
matches,
|
||||||
case 'all': users = client.users.toArray(); break;
|
selected: Math.min(currentPosition, matches.length - 1),
|
||||||
case 'channel': {
|
within: false,
|
||||||
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);
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let matches = (search.length > 0 ? users.filter(user => user.username.toLowerCase().match(regex)) : users)
|
if (type === "user" && searchClues?.users) {
|
||||||
.splice(0, 5)
|
let users: User[] = [];
|
||||||
.filter(x => typeof x !== "undefined");
|
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 (matches.length > 0) {
|
users = users.filter((x) => x._id !== SYSTEM_USER_ID);
|
||||||
let currentPosition =
|
|
||||||
state.type !== "none"
|
|
||||||
? state.selected
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
setState({
|
|
||||||
type: "user",
|
|
||||||
matches,
|
|
||||||
selected: Math.min(currentPosition, matches.length - 1),
|
|
||||||
within: false
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
let matches = (
|
||||||
}
|
search.length > 0
|
||||||
}
|
? users.filter((user) =>
|
||||||
|
user.username.toLowerCase().match(regex),
|
||||||
|
)
|
||||||
|
: users
|
||||||
|
)
|
||||||
|
.splice(0, 5)
|
||||||
|
.filter((x) => typeof x !== "undefined");
|
||||||
|
|
||||||
if (type === 'channel' && searchClues?.channels) {
|
if (matches.length > 0) {
|
||||||
let channels = client.servers.get(searchClues.channels.server)
|
let currentPosition =
|
||||||
?.channels
|
state.type !== "none" ? state.selected : 0;
|
||||||
.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)
|
setState({
|
||||||
.splice(0, 5)
|
type: "user",
|
||||||
.filter(x => typeof x !== "undefined");
|
matches,
|
||||||
|
selected: Math.min(currentPosition, matches.length - 1),
|
||||||
|
within: false,
|
||||||
|
});
|
||||||
|
|
||||||
if (matches.length > 0) {
|
return;
|
||||||
let currentPosition =
|
}
|
||||||
state.type !== "none"
|
}
|
||||||
? state.selected
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
setState({
|
|
||||||
type: "channel",
|
|
||||||
matches,
|
|
||||||
selected: Math.min(currentPosition, matches.length - 1),
|
|
||||||
within: false
|
|
||||||
});
|
|
||||||
|
|
||||||
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 (state.type !== "none") {
|
let matches = (
|
||||||
setState({ type: "none" });
|
search.length > 0
|
||||||
}
|
? channels.filter((channel) =>
|
||||||
}
|
channel.name.toLowerCase().match(regex),
|
||||||
|
)
|
||||||
|
: channels
|
||||||
|
)
|
||||||
|
.splice(0, 5)
|
||||||
|
.filter((x) => typeof x !== "undefined");
|
||||||
|
|
||||||
function selectCurrent(el: HTMLTextAreaElement) {
|
if (matches.length > 0) {
|
||||||
if (state.type !== "none") {
|
let currentPosition =
|
||||||
let result = findSearchString(el);
|
state.type !== "none" ? state.selected : 0;
|
||||||
if (result) {
|
|
||||||
let [_type, search, index] = result;
|
|
||||||
|
|
||||||
let content = el.value.split("");
|
setState({
|
||||||
if (state.type === "emoji") {
|
type: "channel",
|
||||||
content.splice(
|
matches,
|
||||||
index,
|
selected: Math.min(currentPosition, matches.length - 1),
|
||||||
search.length,
|
within: false,
|
||||||
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(""));
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onClick(ev: JSX.TargetedMouseEvent<HTMLButtonElement>) {
|
if (state.type !== "none") {
|
||||||
ev.preventDefault();
|
setState({ type: "none" });
|
||||||
selectCurrent(document.querySelector("#message")!);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onKeyDown(e: KeyboardEvent) {
|
function selectCurrent(el: HTMLTextAreaElement) {
|
||||||
if (focused && state.type !== 'none') {
|
if (state.type !== "none") {
|
||||||
if (e.key === "ArrowUp") {
|
let result = findSearchString(el);
|
||||||
e.preventDefault();
|
if (result) {
|
||||||
if (state.selected > 0) {
|
let [_type, search, index] = result;
|
||||||
setState({
|
|
||||||
...state,
|
|
||||||
selected: state.selected - 1
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
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,
|
||||||
|
"> ",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (e.key === "ArrowDown") {
|
setValue(content.join(""));
|
||||||
e.preventDefault();
|
}
|
||||||
if (state.selected < state.matches.length - 1) {
|
}
|
||||||
setState({
|
}
|
||||||
...state,
|
|
||||||
selected: state.selected + 1
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
function onClick(ev: JSX.TargetedMouseEvent<HTMLButtonElement>) {
|
||||||
}
|
ev.preventDefault();
|
||||||
|
selectCurrent(document.querySelector("#message")!);
|
||||||
|
}
|
||||||
|
|
||||||
if (e.key === "Enter" || e.key === "Tab") {
|
function onKeyDown(e: KeyboardEvent) {
|
||||||
e.preventDefault();
|
if (focused && state.type !== "none") {
|
||||||
selectCurrent(
|
if (e.key === "ArrowUp") {
|
||||||
e.currentTarget as HTMLTextAreaElement
|
e.preventDefault();
|
||||||
);
|
if (state.selected > 0) {
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
selected: state.selected - 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
if (e.key === "ArrowDown") {
|
||||||
}
|
e.preventDefault();
|
||||||
|
if (state.selected < state.matches.length - 1) {
|
||||||
|
setState({
|
||||||
|
...state,
|
||||||
|
selected: state.selected + 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function onKeyUp(e: KeyboardEvent) {
|
return true;
|
||||||
if (e.currentTarget !== null) {
|
}
|
||||||
// @ts-expect-error
|
|
||||||
onChange(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onFocus(ev: JSX.TargetedFocusEvent<HTMLTextAreaElement>) {
|
if (e.key === "Enter" || e.key === "Tab") {
|
||||||
setFocused(true);
|
e.preventDefault();
|
||||||
onChange(ev);
|
selectCurrent(e.currentTarget as HTMLTextAreaElement);
|
||||||
}
|
|
||||||
|
|
||||||
function onBlur() {
|
return true;
|
||||||
if (state.type !== 'none' && state.within) return;
|
}
|
||||||
setFocused(false);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return false;
|
||||||
state: focused ? state : { type: 'none' },
|
}
|
||||||
setState,
|
|
||||||
|
|
||||||
onClick,
|
function onKeyUp(e: KeyboardEvent) {
|
||||||
onChange,
|
if (e.currentTarget !== null) {
|
||||||
onKeyUp,
|
// @ts-expect-error
|
||||||
onKeyDown,
|
onChange(e);
|
||||||
onFocus,
|
}
|
||||||
onBlur
|
}
|
||||||
}
|
|
||||||
|
function onFocus(ev: JSX.TargetedFocusEvent<HTMLTextAreaElement>) {
|
||||||
|
setFocused(true);
|
||||||
|
onChange(ev);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBlur() {
|
||||||
|
if (state.type !== "none" && state.within) return;
|
||||||
|
setFocused(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state: focused ? state : { type: "none" },
|
||||||
|
setState,
|
||||||
|
|
||||||
|
onClick,
|
||||||
|
onChange,
|
||||||
|
onKeyUp,
|
||||||
|
onKeyDown,
|
||||||
|
onFocus,
|
||||||
|
onBlur,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const Base = styled.div<{ detached?: boolean }>`
|
const Base = styled.div<{ detached?: boolean }>`
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
> div {
|
> div {
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
background: var(--primary-header);
|
background: var(--primary-header);
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
margin: 4px;
|
margin: 4px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
border: none;
|
border: none;
|
||||||
display: flex;
|
display: flex;
|
||||||
font-size: 1em;
|
font-size: 1em;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
width: calc(100% - 12px);
|
width: calc(100% - 12px);
|
||||||
|
|
||||||
span {
|
span {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background: var(--primary-background);
|
background: var(--primary-background);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
${ props => props.detached && css`
|
${(props) =>
|
||||||
bottom: 8px;
|
props.detached &&
|
||||||
|
css`
|
||||||
|
bottom: 8px;
|
||||||
|
|
||||||
> div {
|
> div {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
` }
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default function AutoComplete({ detached, state, setState, onClick }: Pick<AutoCompleteProps, 'detached' | 'state' | 'setState' | 'onClick'>) {
|
export default function AutoComplete({
|
||||||
return (
|
detached,
|
||||||
<Base detached={detached}>
|
state,
|
||||||
<div>
|
setState,
|
||||||
{state.type === "emoji" &&
|
onClick,
|
||||||
state.matches.map((match, i) => (
|
}: Pick<AutoCompleteProps, "detached" | "state" | "setState" | "onClick">) {
|
||||||
<button
|
return (
|
||||||
className={i === state.selected ? "active" : ''}
|
<Base detached={detached}>
|
||||||
onMouseEnter={() =>
|
<div>
|
||||||
(i !== state.selected ||
|
{state.type === "emoji" &&
|
||||||
!state.within) &&
|
state.matches.map((match, i) => (
|
||||||
setState({
|
<button
|
||||||
...state,
|
className={i === state.selected ? "active" : ""}
|
||||||
selected: i,
|
onMouseEnter={() =>
|
||||||
within: true
|
(i !== state.selected || !state.within) &&
|
||||||
})
|
setState({
|
||||||
}
|
...state,
|
||||||
onMouseLeave={() =>
|
selected: i,
|
||||||
state.within &&
|
within: true,
|
||||||
setState({
|
})
|
||||||
...state,
|
}
|
||||||
within: false
|
onMouseLeave={() =>
|
||||||
})
|
state.within &&
|
||||||
}
|
setState({
|
||||||
onClick={onClick}>
|
...state,
|
||||||
<Emoji emoji={(emojiDictionary as Record<string, string>)[match]} size={20} />
|
within: false,
|
||||||
:{match}:
|
})
|
||||||
</button>
|
}
|
||||||
))}
|
onClick={onClick}>
|
||||||
{state.type === "user" &&
|
<Emoji
|
||||||
state.matches.map((match, i) => (
|
emoji={
|
||||||
<button
|
(emojiDictionary as Record<string, string>)[
|
||||||
className={i === state.selected ? "active" : ''}
|
match
|
||||||
onMouseEnter={() =>
|
]
|
||||||
(i !== state.selected ||
|
}
|
||||||
!state.within) &&
|
size={20}
|
||||||
setState({
|
/>
|
||||||
...state,
|
:{match}:
|
||||||
selected: i,
|
</button>
|
||||||
within: true
|
))}
|
||||||
})
|
{state.type === "user" &&
|
||||||
}
|
state.matches.map((match, i) => (
|
||||||
onMouseLeave={() =>
|
<button
|
||||||
state.within &&
|
className={i === state.selected ? "active" : ""}
|
||||||
setState({
|
onMouseEnter={() =>
|
||||||
...state,
|
(i !== state.selected || !state.within) &&
|
||||||
within: false
|
setState({
|
||||||
})
|
...state,
|
||||||
}
|
selected: i,
|
||||||
onClick={onClick}>
|
within: true,
|
||||||
<UserIcon
|
})
|
||||||
size={24}
|
}
|
||||||
target={match}
|
onMouseLeave={() =>
|
||||||
status={true} />
|
state.within &&
|
||||||
{match.username}
|
setState({
|
||||||
</button>
|
...state,
|
||||||
))}
|
within: false,
|
||||||
{state.type === "channel" &&
|
})
|
||||||
state.matches.map((match, i) => (
|
}
|
||||||
<button
|
onClick={onClick}>
|
||||||
className={i === state.selected ? "active" : ''}
|
<UserIcon size={24} target={match} status={true} />
|
||||||
onMouseEnter={() =>
|
{match.username}
|
||||||
(i !== state.selected ||
|
</button>
|
||||||
!state.within) &&
|
))}
|
||||||
setState({
|
{state.type === "channel" &&
|
||||||
...state,
|
state.matches.map((match, i) => (
|
||||||
selected: i,
|
<button
|
||||||
within: true
|
className={i === state.selected ? "active" : ""}
|
||||||
})
|
onMouseEnter={() =>
|
||||||
}
|
(i !== state.selected || !state.within) &&
|
||||||
onMouseLeave={() =>
|
setState({
|
||||||
state.within &&
|
...state,
|
||||||
setState({
|
selected: i,
|
||||||
...state,
|
within: true,
|
||||||
within: false
|
})
|
||||||
})
|
}
|
||||||
}
|
onMouseLeave={() =>
|
||||||
onClick={onClick}>
|
state.within &&
|
||||||
<ChannelIcon
|
setState({
|
||||||
size={24}
|
...state,
|
||||||
target={match} />
|
within: false,
|
||||||
{match.name}
|
})
|
||||||
</button>
|
}
|
||||||
))}
|
onClick={onClick}>
|
||||||
</div>
|
<ChannelIcon size={24} target={match} />
|
||||||
</Base>
|
{match.name}
|
||||||
)
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Base>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,43 +1,65 @@
|
||||||
import { useContext } from "preact/hooks";
|
|
||||||
import { Channels } from "revolt.js/dist/api/objects";
|
|
||||||
import { Hash, VolumeFull } from "@styled-icons/boxicons-regular";
|
import { Hash, VolumeFull } from "@styled-icons/boxicons-regular";
|
||||||
import { ImageIconBase, IconBaseProps } from "./IconBase";
|
import { Channels } from "revolt.js/dist/api/objects";
|
||||||
|
|
||||||
|
import { useContext } from "preact/hooks";
|
||||||
|
|
||||||
import { AppContext } from "../../context/revoltjs/RevoltClient";
|
import { AppContext } from "../../context/revoltjs/RevoltClient";
|
||||||
|
|
||||||
interface Props extends IconBaseProps<Channels.GroupChannel | Channels.TextChannel | Channels.VoiceChannel> {
|
import { ImageIconBase, IconBaseProps } from "./IconBase";
|
||||||
isServerChannel?: boolean;
|
import fallback from "./assets/group.png";
|
||||||
|
|
||||||
|
interface Props
|
||||||
|
extends IconBaseProps<
|
||||||
|
Channels.GroupChannel | Channels.TextChannel | Channels.VoiceChannel
|
||||||
|
> {
|
||||||
|
isServerChannel?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
import fallback from './assets/group.png';
|
export default function ChannelIcon(
|
||||||
|
props: Props & Omit<JSX.HTMLAttributes<HTMLImageElement>, keyof Props>,
|
||||||
|
) {
|
||||||
|
const client = useContext(AppContext);
|
||||||
|
|
||||||
export default function ChannelIcon(props: Props & Omit<JSX.HTMLAttributes<HTMLImageElement>, keyof Props>) {
|
const {
|
||||||
const client = useContext(AppContext);
|
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;
|
if (typeof iconURL === "undefined") {
|
||||||
const iconURL = client.generateFileURL(target?.icon ?? attachment, { max_side: 256 }, animate);
|
if (isServerChannel) {
|
||||||
const isServerChannel = server || (target && (target.channel_type === 'TextChannel' || target.channel_type === 'VoiceChannel'));
|
if (target?.channel_type === "VoiceChannel") {
|
||||||
|
return <VolumeFull size={size} />;
|
||||||
|
} else {
|
||||||
|
return <Hash size={size} />;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (typeof iconURL === 'undefined') {
|
return (
|
||||||
if (isServerChannel) {
|
// ! fixme: replace fallback with <picture /> + <source />
|
||||||
if (target?.channel_type === 'VoiceChannel') {
|
<ImageIconBase
|
||||||
return (
|
{...imgProps}
|
||||||
<VolumeFull size={size} />
|
width={size}
|
||||||
)
|
height={size}
|
||||||
} else {
|
aria-hidden="true"
|
||||||
return (
|
square={isServerChannel}
|
||||||
<Hash size={size} />
|
src={iconURL ?? fallback}
|
||||||
)
|
/>
|
||||||
}
|
);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
// ! fixme: replace fallback with <picture /> + <source />
|
|
||||||
<ImageIconBase {...imgProps}
|
|
||||||
width={size}
|
|
||||||
height={size}
|
|
||||||
aria-hidden="true"
|
|
||||||
square={isServerChannel}
|
|
||||||
src={iconURL ?? fallback} />
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,50 +1,59 @@
|
||||||
import Details from "../ui/Details";
|
|
||||||
import { State, store } from "../../redux";
|
|
||||||
import { Action } from "../../redux/reducers";
|
|
||||||
import { Children } from "../../types/Preact";
|
|
||||||
import { ChevronDown } from "@styled-icons/boxicons-regular";
|
import { ChevronDown } from "@styled-icons/boxicons-regular";
|
||||||
|
|
||||||
|
import { State, store } from "../../redux";
|
||||||
|
import { Action } from "../../redux/reducers";
|
||||||
|
|
||||||
|
import Details from "../ui/Details";
|
||||||
|
|
||||||
|
import { Children } from "../../types/Preact";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
defaultValue: boolean;
|
defaultValue: boolean;
|
||||||
|
|
||||||
sticky?: boolean;
|
sticky?: boolean;
|
||||||
large?: boolean;
|
large?: boolean;
|
||||||
|
|
||||||
summary: Children;
|
summary: Children;
|
||||||
children: Children;
|
children: Children;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CollapsibleSection({ id, defaultValue, summary, children, ...detailsProps }: Props) {
|
export default function CollapsibleSection({
|
||||||
const state: State = store.getState();
|
id,
|
||||||
|
defaultValue,
|
||||||
|
summary,
|
||||||
|
children,
|
||||||
|
...detailsProps
|
||||||
|
}: Props) {
|
||||||
|
const state: State = store.getState();
|
||||||
|
|
||||||
function setState(state: boolean) {
|
function setState(state: boolean) {
|
||||||
if (state === defaultValue) {
|
if (state === defaultValue) {
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
type: 'SECTION_TOGGLE_UNSET',
|
type: "SECTION_TOGGLE_UNSET",
|
||||||
id
|
id,
|
||||||
} as Action);
|
} as Action);
|
||||||
} else {
|
} else {
|
||||||
store.dispatch({
|
store.dispatch({
|
||||||
type: 'SECTION_TOGGLE_SET',
|
type: "SECTION_TOGGLE_SET",
|
||||||
id,
|
id,
|
||||||
state
|
state,
|
||||||
} as Action);
|
} as Action);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Details
|
<Details
|
||||||
open={state.sectionToggle[id] ?? defaultValue}
|
open={state.sectionToggle[id] ?? defaultValue}
|
||||||
onToggle={e => setState(e.currentTarget.open)}
|
onToggle={(e) => setState(e.currentTarget.open)}
|
||||||
{...detailsProps}>
|
{...detailsProps}>
|
||||||
<summary>
|
<summary>
|
||||||
<div class="padding">
|
<div class="padding">
|
||||||
<ChevronDown size={20} />
|
<ChevronDown size={20} />
|
||||||
{ summary }
|
{summary}
|
||||||
</div>
|
</div>
|
||||||
</summary>
|
</summary>
|
||||||
{ children }
|
{children}
|
||||||
</Details>
|
</Details>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,62 +1,72 @@
|
||||||
import { EmojiPacks } from '../../redux/reducers/settings';
|
import { EmojiPacks } from "../../redux/reducers/settings";
|
||||||
|
|
||||||
var EMOJI_PACK = 'mutant';
|
var EMOJI_PACK = "mutant";
|
||||||
const REVISION = 3;
|
const REVISION = 3;
|
||||||
|
|
||||||
export function setEmojiPack(pack: EmojiPacks) {
|
export function setEmojiPack(pack: EmojiPacks) {
|
||||||
EMOJI_PACK = pack;
|
EMOJI_PACK = pack;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Originally taken from Twemoji source code,
|
// Originally taken from Twemoji source code,
|
||||||
// re-written by bree to be more readable.
|
// re-written by bree to be more readable.
|
||||||
function codePoints(rune: string) {
|
function codePoints(rune: string) {
|
||||||
const pairs = [];
|
const pairs = [];
|
||||||
let low = 0;
|
let low = 0;
|
||||||
let i = 0;
|
let i = 0;
|
||||||
|
|
||||||
while (i < rune.length) {
|
while (i < rune.length) {
|
||||||
const charCode = rune.charCodeAt(i++);
|
const charCode = rune.charCodeAt(i++);
|
||||||
if (low) {
|
if (low) {
|
||||||
pairs.push(0x10000 + ((low - 0xd800) << 10) + (charCode - 0xdc00));
|
pairs.push(0x10000 + ((low - 0xd800) << 10) + (charCode - 0xdc00));
|
||||||
low = 0;
|
low = 0;
|
||||||
} else if (0xd800 <= charCode && charCode <= 0xdbff) {
|
} else if (0xd800 <= charCode && charCode <= 0xdbff) {
|
||||||
low = charCode;
|
low = charCode;
|
||||||
} else {
|
} else {
|
||||||
pairs.push(charCode);
|
pairs.push(charCode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return pairs;
|
return pairs;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Taken from Twemoji source code.
|
// Taken from Twemoji source code.
|
||||||
// scripts/build.js#344
|
// scripts/build.js#344
|
||||||
// grabTheRightIcon(rawText);
|
// grabTheRightIcon(rawText);
|
||||||
const UFE0Fg = /\uFE0F/g;
|
const UFE0Fg = /\uFE0F/g;
|
||||||
const U200D = String.fromCharCode(0x200D);
|
const U200D = String.fromCharCode(0x200d);
|
||||||
function toCodePoint(rune: string) {
|
function toCodePoint(rune: string) {
|
||||||
return codePoints(rune.indexOf(U200D) < 0 ? rune.replace(UFE0Fg, '') : rune)
|
return codePoints(rune.indexOf(U200D) < 0 ? rune.replace(UFE0Fg, "") : rune)
|
||||||
.map((val) => val.toString(16))
|
.map((val) => val.toString(16))
|
||||||
.join("-")
|
.join("-");
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseEmoji(emoji: string) {
|
function parseEmoji(emoji: string) {
|
||||||
let codepoint = toCodePoint(emoji);
|
let codepoint = toCodePoint(emoji);
|
||||||
return `https://static.revolt.chat/emoji/${EMOJI_PACK}/${codepoint}.svg?rev=${REVISION}`;
|
return `https://static.revolt.chat/emoji/${EMOJI_PACK}/${codepoint}.svg?rev=${REVISION}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Emoji({ emoji, size }: { emoji: string, size?: number }) {
|
export default function Emoji({
|
||||||
return (
|
emoji,
|
||||||
<img
|
size,
|
||||||
alt={emoji}
|
}: {
|
||||||
className="emoji"
|
emoji: string;
|
||||||
draggable={false}
|
size?: number;
|
||||||
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) {
|
export function generateEmoji(emoji: string) {
|
||||||
return `<img class="emoji" draggable="false" alt="${emoji}" src="${parseEmoji(emoji)}" />`;
|
return `<img class="emoji" draggable="false" alt="${emoji}" src="${parseEmoji(
|
||||||
|
emoji,
|
||||||
|
)}" />`;
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,36 +2,40 @@ import { Attachment } from "revolt.js/dist/api/objects";
|
||||||
import styled, { css } from "styled-components";
|
import styled, { css } from "styled-components";
|
||||||
|
|
||||||
export interface IconBaseProps<T> {
|
export interface IconBaseProps<T> {
|
||||||
target?: T;
|
target?: T;
|
||||||
attachment?: Attachment;
|
attachment?: Attachment;
|
||||||
|
|
||||||
size: number;
|
size: number;
|
||||||
animate?: boolean;
|
animate?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface IconModifiers {
|
interface IconModifiers {
|
||||||
square?: boolean
|
square?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default styled.svg<IconModifiers>`
|
export default styled.svg<IconModifiers>`
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
img {
|
img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
|
||||||
${ props => !props.square && css`
|
${(props) =>
|
||||||
border-radius: 50%;
|
!props.square &&
|
||||||
` }
|
css`
|
||||||
}
|
border-radius: 50%;
|
||||||
|
`}
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const ImageIconBase = styled.img<IconModifiers>`
|
export const ImageIconBase = styled.img<IconModifiers>`
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
|
||||||
${ props => !props.square && css`
|
${(props) =>
|
||||||
border-radius: 50%;
|
!props.square &&
|
||||||
` }
|
css`
|
||||||
|
border-radius: 50%;
|
||||||
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -1,40 +1,38 @@
|
||||||
import ComboBox from "../ui/ComboBox";
|
|
||||||
import { dispatch } from "../../redux";
|
import { dispatch } from "../../redux";
|
||||||
import { connectState } from "../../redux/connector";
|
import { connectState } from "../../redux/connector";
|
||||||
import { Language, LanguageEntry, Languages } from "../../context/Locale";
|
|
||||||
|
import { Language, Languages } from "../../context/Locale";
|
||||||
|
|
||||||
|
import ComboBox from "../ui/ComboBox";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
locale: string;
|
locale: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function LocaleSelector(props: Props) {
|
export function LocaleSelector(props: Props) {
|
||||||
return (
|
return (
|
||||||
<ComboBox
|
<ComboBox
|
||||||
value={props.locale}
|
value={props.locale}
|
||||||
onChange={e =>
|
onChange={(e) =>
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "SET_LOCALE",
|
type: "SET_LOCALE",
|
||||||
locale: e.currentTarget.value as Language
|
locale: e.currentTarget.value as Language,
|
||||||
})
|
})
|
||||||
}
|
}>
|
||||||
>
|
{Object.keys(Languages).map((x) => {
|
||||||
{Object.keys(Languages).map(x => {
|
const l = Languages[x as keyof typeof Languages];
|
||||||
const l = Languages[x as keyof typeof Languages];
|
return (
|
||||||
return (
|
<option value={x}>
|
||||||
<option value={x}>
|
{l.emoji} {l.display}
|
||||||
{l.emoji} {l.display}
|
</option>
|
||||||
</option>
|
);
|
||||||
);
|
})}
|
||||||
})}
|
</ComboBox>
|
||||||
</ComboBox>
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connectState(
|
export default connectState(LocaleSelector, (state) => {
|
||||||
LocaleSelector,
|
return {
|
||||||
state => {
|
locale: state.locale,
|
||||||
return {
|
};
|
||||||
locale: state.locale
|
});
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
|
@ -1,40 +1,49 @@
|
||||||
import Header from "../ui/Header";
|
|
||||||
import styled from "styled-components";
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import IconButton from "../ui/IconButton";
|
|
||||||
import { Cog } from "@styled-icons/boxicons-solid";
|
import { Cog } from "@styled-icons/boxicons-solid";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
import { Server } from "revolt.js/dist/api/objects";
|
import { Server } from "revolt.js/dist/api/objects";
|
||||||
import { ServerPermission } from "revolt.js/dist/api/permissions";
|
import { ServerPermission } from "revolt.js/dist/api/permissions";
|
||||||
|
import styled from "styled-components";
|
||||||
|
|
||||||
import { HookContext, useServerPermission } from "../../context/revoltjs/hooks";
|
import { HookContext, useServerPermission } from "../../context/revoltjs/hooks";
|
||||||
|
|
||||||
|
import Header from "../ui/Header";
|
||||||
|
import IconButton from "../ui/IconButton";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
server: Server,
|
server: Server;
|
||||||
ctx: HookContext
|
ctx: HookContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ServerName = styled.div`
|
const ServerName = styled.div`
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default function ServerHeader({ server, ctx }: Props) {
|
export default function ServerHeader({ server, ctx }: Props) {
|
||||||
const permissions = useServerPermission(server._id, ctx);
|
const permissions = useServerPermission(server._id, ctx);
|
||||||
const bannerURL = ctx.client.servers.getBannerURL(server._id, { width: 480 }, true);
|
const bannerURL = ctx.client.servers.getBannerURL(
|
||||||
|
server._id,
|
||||||
|
{ width: 480 },
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Header borders
|
<Header
|
||||||
placement="secondary"
|
borders
|
||||||
background={typeof bannerURL !== 'undefined'}
|
placement="secondary"
|
||||||
style={{ background: bannerURL ? `url('${bannerURL}')` : undefined }}>
|
background={typeof bannerURL !== "undefined"}
|
||||||
<ServerName>
|
style={{
|
||||||
{ server.name }
|
background: bannerURL ? `url('${bannerURL}')` : undefined,
|
||||||
</ServerName>
|
}}>
|
||||||
{ (permissions & ServerPermission.ManageServer) > 0 && <div className="actions">
|
<ServerName>{server.name}</ServerName>
|
||||||
<Link to={`/server/${server._id}/settings`}>
|
{(permissions & ServerPermission.ManageServer) > 0 && (
|
||||||
<IconButton>
|
<div className="actions">
|
||||||
<Cog size={24} />
|
<Link to={`/server/${server._id}/settings`}>
|
||||||
</IconButton>
|
<IconButton>
|
||||||
</Link>
|
<Cog size={24} />
|
||||||
</div> }
|
</IconButton>
|
||||||
</Header>
|
</Link>
|
||||||
)
|
</div>
|
||||||
|
)}
|
||||||
|
</Header>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,47 +1,68 @@
|
||||||
import styled from "styled-components";
|
|
||||||
import { useContext } from "preact/hooks";
|
|
||||||
import { Server } from "revolt.js/dist/api/objects";
|
import { Server } from "revolt.js/dist/api/objects";
|
||||||
import { IconBaseProps, ImageIconBase } from "./IconBase";
|
import styled from "styled-components";
|
||||||
|
|
||||||
|
import { useContext } from "preact/hooks";
|
||||||
|
|
||||||
import { AppContext } from "../../context/revoltjs/RevoltClient";
|
import { AppContext } from "../../context/revoltjs/RevoltClient";
|
||||||
|
|
||||||
|
import { IconBaseProps, ImageIconBase } from "./IconBase";
|
||||||
|
|
||||||
interface Props extends IconBaseProps<Server> {
|
interface Props extends IconBaseProps<Server> {
|
||||||
server_name?: string;
|
server_name?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ServerText = styled.div`
|
const ServerText = styled.div`
|
||||||
display: grid;
|
display: grid;
|
||||||
padding: .2em;
|
padding: 0.2em;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
background: var(--primary-background);
|
background: var(--primary-background);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const fallback = '/assets/group.png';
|
const fallback = "/assets/group.png";
|
||||||
export default function ServerIcon(props: Props & Omit<JSX.HTMLAttributes<HTMLImageElement>, keyof Props>) {
|
export default function ServerIcon(
|
||||||
const client = useContext(AppContext);
|
props: Props & Omit<JSX.HTMLAttributes<HTMLImageElement>, keyof Props>,
|
||||||
|
) {
|
||||||
|
const client = useContext(AppContext);
|
||||||
|
|
||||||
const { target, attachment, size, animate, server_name, children, as, ...imgProps } = props;
|
const {
|
||||||
const iconURL = client.generateFileURL(target?.icon ?? attachment, { max_side: 256 }, animate);
|
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') {
|
if (typeof iconURL === "undefined") {
|
||||||
const name = target?.name ?? server_name ?? '';
|
const name = target?.name ?? server_name ?? "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ServerText style={{ width: size, height: size }}>
|
<ServerText style={{ width: size, height: size }}>
|
||||||
{ name.split(' ')
|
{name
|
||||||
.map(x => x[0])
|
.split(" ")
|
||||||
.filter(x => typeof x !== 'undefined') }
|
.map((x) => x[0])
|
||||||
</ServerText>
|
.filter((x) => typeof x !== "undefined")}
|
||||||
)
|
</ServerText>
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ImageIconBase {...imgProps}
|
<ImageIconBase
|
||||||
width={size}
|
{...imgProps}
|
||||||
height={size}
|
width={size}
|
||||||
aria-hidden="true"
|
height={size}
|
||||||
src={iconURL} />
|
aria-hidden="true"
|
||||||
);
|
src={iconURL}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,49 +1,60 @@
|
||||||
import { Text } from "preact-i18n";
|
import Tippy, { TippyProps } from "@tippyjs/react";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { Children } from "../../types/Preact";
|
|
||||||
import Tippy, { TippyProps } from '@tippyjs/react';
|
|
||||||
|
|
||||||
type Props = Omit<TippyProps, 'children'> & {
|
import { Text } from "preact-i18n";
|
||||||
children: Children;
|
|
||||||
content: Children;
|
import { Children } from "../../types/Preact";
|
||||||
}
|
|
||||||
|
type Props = Omit<TippyProps, "children"> & {
|
||||||
|
children: Children;
|
||||||
|
content: Children;
|
||||||
|
};
|
||||||
|
|
||||||
export default function Tooltip(props: Props) {
|
export default function Tooltip(props: Props) {
|
||||||
const { children, content, ...tippyProps } = props;
|
const { children, content, ...tippyProps } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tippy content={content} {...tippyProps}>
|
<Tippy content={content} {...tippyProps}>
|
||||||
{/*
|
{/*
|
||||||
// @ts-expect-error */}
|
// @ts-expect-error */}
|
||||||
<div>{ children }</div>
|
<div>{children}</div>
|
||||||
</Tippy>
|
</Tippy>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const PermissionTooltipBase = styled.div`
|
const PermissionTooltipBase = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
span {
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
color: var(--secondary-foreground);
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
span {
|
||||||
font-family: var(--monoscape-font);
|
font-weight: 700;
|
||||||
}
|
text-transform: uppercase;
|
||||||
|
color: var(--secondary-foreground);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
font-family: var(--monoscape-font);
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export function PermissionTooltip(props: Omit<Props, 'content'> & { permission: string }) {
|
export function PermissionTooltip(
|
||||||
const { permission, ...tooltipProps } = props;
|
props: Omit<Props, "content"> & { permission: string },
|
||||||
|
) {
|
||||||
|
const { permission, ...tooltipProps } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip content={<PermissionTooltipBase>
|
<Tooltip
|
||||||
<span><Text id="app.permissions.required" /></span>
|
content={
|
||||||
<code>{ permission }</code>
|
<PermissionTooltipBase>
|
||||||
</PermissionTooltipBase>} {...tooltipProps} />
|
<span>
|
||||||
)
|
<Text id="app.permissions.required" />
|
||||||
|
</span>
|
||||||
|
<code>{permission}</code>
|
||||||
|
</PermissionTooltipBase>
|
||||||
|
}
|
||||||
|
{...tooltipProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,26 +1,31 @@
|
||||||
import { updateSW } from "../../main";
|
|
||||||
import IconButton from "../ui/IconButton";
|
|
||||||
import { ThemeContext } from "../../context/Theme";
|
|
||||||
import { Download } from "@styled-icons/boxicons-regular";
|
import { Download } from "@styled-icons/boxicons-regular";
|
||||||
import { internalSubscribe } from "../../lib/eventEmitter";
|
|
||||||
import { useContext, useEffect, useState } from "preact/hooks";
|
import { useContext, useEffect, useState } from "preact/hooks";
|
||||||
|
|
||||||
|
import { internalSubscribe } from "../../lib/eventEmitter";
|
||||||
|
|
||||||
|
import { ThemeContext } from "../../context/Theme";
|
||||||
|
|
||||||
|
import IconButton from "../ui/IconButton";
|
||||||
|
|
||||||
|
import { updateSW } from "../../main";
|
||||||
|
|
||||||
var pendingUpdate = false;
|
var pendingUpdate = false;
|
||||||
internalSubscribe('PWA', 'update', () => pendingUpdate = true);
|
internalSubscribe("PWA", "update", () => (pendingUpdate = true));
|
||||||
|
|
||||||
export default function UpdateIndicator() {
|
export default function UpdateIndicator() {
|
||||||
const [ pending, setPending ] = useState(pendingUpdate);
|
const [pending, setPending] = useState(pendingUpdate);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return internalSubscribe('PWA', 'update', () => setPending(true));
|
return internalSubscribe("PWA", "update", () => setPending(true));
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!pending) return null;
|
if (!pending) return null;
|
||||||
const theme = useContext(ThemeContext);
|
const theme = useContext(ThemeContext);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IconButton onClick={() => updateSW(true)}>
|
<IconButton onClick={() => updateSW(true)}>
|
||||||
<Download size={22} color={theme.success} />
|
<Download size={22} color={theme.success} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,78 +1,133 @@
|
||||||
import Embed from "./embed/Embed";
|
import { attachContextMenu } from "preact-context-menu";
|
||||||
|
import { memo } from "preact/compat";
|
||||||
|
import { useContext } from "preact/hooks";
|
||||||
|
|
||||||
|
import { QueuedMessage } from "../../../redux/reducers/queue";
|
||||||
|
|
||||||
|
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
||||||
|
import { AppContext } from "../../../context/revoltjs/RevoltClient";
|
||||||
|
import { useUser } from "../../../context/revoltjs/hooks";
|
||||||
|
import { MessageObject } from "../../../context/revoltjs/util";
|
||||||
|
|
||||||
|
import Overline from "../../ui/Overline";
|
||||||
|
|
||||||
|
import { Children } from "../../../types/Preact";
|
||||||
|
import Markdown from "../../markdown/Markdown";
|
||||||
import UserIcon from "../user/UserIcon";
|
import UserIcon from "../user/UserIcon";
|
||||||
import { Username } from "../user/UserShort";
|
import { Username } from "../user/UserShort";
|
||||||
import Markdown from "../../markdown/Markdown";
|
import MessageBase, {
|
||||||
import { Children } from "../../../types/Preact";
|
MessageContent,
|
||||||
|
MessageDetail,
|
||||||
|
MessageInfo,
|
||||||
|
} from "./MessageBase";
|
||||||
import Attachment from "./attachments/Attachment";
|
import Attachment from "./attachments/Attachment";
|
||||||
import { attachContextMenu } from "preact-context-menu";
|
|
||||||
import { useUser } from "../../../context/revoltjs/hooks";
|
|
||||||
import { QueuedMessage } from "../../../redux/reducers/queue";
|
|
||||||
import { MessageObject } from "../../../context/revoltjs/util";
|
|
||||||
import MessageBase, { MessageContent, MessageDetail, MessageInfo } from "./MessageBase";
|
|
||||||
import Overline from "../../ui/Overline";
|
|
||||||
import { useContext } from "preact/hooks";
|
|
||||||
import { AppContext } from "../../../context/revoltjs/RevoltClient";
|
|
||||||
import { memo } from "preact/compat";
|
|
||||||
import { MessageReply } from "./attachments/MessageReply";
|
import { MessageReply } from "./attachments/MessageReply";
|
||||||
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
import Embed from "./embed/Embed";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
attachContext?: boolean
|
attachContext?: boolean;
|
||||||
queued?: QueuedMessage
|
queued?: QueuedMessage;
|
||||||
message: MessageObject
|
message: MessageObject;
|
||||||
contrast?: boolean
|
contrast?: boolean;
|
||||||
content?: Children
|
content?: Children;
|
||||||
head?: boolean
|
head?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Message({ attachContext, message, contrast, content: replacement, head: preferHead, queued }: Props) {
|
function Message({
|
||||||
// TODO: Can improve re-renders here by providing a list
|
attachContext,
|
||||||
// TODO: of dependencies. We only need to update on u/avatar.
|
message,
|
||||||
const user = useUser(message.author);
|
contrast,
|
||||||
const client = useContext(AppContext);
|
content: replacement,
|
||||||
const { openScreen } = useIntermediate();
|
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();
|
||||||
|
|
||||||
const content = message.content as string;
|
const content = message.content as string;
|
||||||
const head = preferHead || (message.replies && message.replies.length > 0);
|
const head = preferHead || (message.replies && message.replies.length > 0);
|
||||||
|
|
||||||
// ! FIXME: tell fatal to make this type generic
|
// ! FIXME: tell fatal to make this type generic
|
||||||
// bree: Fatal please...
|
// bree: Fatal please...
|
||||||
const userContext = attachContext
|
const userContext = attachContext
|
||||||
? attachContextMenu('Menu', { user: message.author, contextualChannel: message.channel }) as any
|
? (attachContextMenu("Menu", {
|
||||||
: undefined;
|
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 (
|
return (
|
||||||
<div id={message._id}>
|
<div id={message._id}>
|
||||||
{ message.replies?.map((message_id, index) => <MessageReply index={index} id={message_id} channel={message.channel} />) }
|
{message.replies?.map((message_id, index) => (
|
||||||
<MessageBase
|
<MessageReply
|
||||||
head={head && !(message.replies && message.replies.length > 0)}
|
index={index}
|
||||||
contrast={contrast}
|
id={message_id}
|
||||||
sending={typeof queued !== 'undefined'}
|
channel={message.channel}
|
||||||
mention={message.mentions?.includes(client.user!._id)}
|
/>
|
||||||
failed={typeof queued?.error !== 'undefined'}
|
))}
|
||||||
onContextMenu={attachContext ? attachContextMenu('Menu', { message, contextualChannel: message.channel, queued }) : undefined}>
|
<MessageBase
|
||||||
<MessageInfo>
|
head={head && !(message.replies && message.replies.length > 0)}
|
||||||
{ head ?
|
contrast={contrast}
|
||||||
<UserIcon target={user} size={36} onContextMenu={userContext} onClick={openProfile} /> :
|
sending={typeof queued !== "undefined"}
|
||||||
<MessageDetail message={message} position="left" /> }
|
mention={message.mentions?.includes(client.user!._id)}
|
||||||
</MessageInfo>
|
failed={typeof queued?.error !== "undefined"}
|
||||||
<MessageContent>
|
onContextMenu={
|
||||||
{ head && <span className="detail">
|
attachContext
|
||||||
<Username className="author" user={user} onContextMenu={userContext} onClick={openProfile} />
|
? attachContextMenu("Menu", {
|
||||||
<MessageDetail message={message} position="top" />
|
message,
|
||||||
</span> }
|
contextualChannel: message.channel,
|
||||||
{ replacement ?? <Markdown content={content} /> }
|
queued,
|
||||||
{ queued?.error && <Overline type="error" error={queued.error} /> }
|
})
|
||||||
{ message.attachments?.map((attachment, index) =>
|
: undefined
|
||||||
<Attachment key={index} attachment={attachment} hasContent={ index > 0 || content.length > 0 } />) }
|
}>
|
||||||
{ message.embeds?.map((embed, index) =>
|
<MessageInfo>
|
||||||
<Embed key={index} embed={embed} />) }
|
{head ? (
|
||||||
</MessageContent>
|
<UserIcon
|
||||||
</MessageBase>
|
target={user}
|
||||||
</div>
|
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);
|
export default memo(Message);
|
||||||
|
|
|
@ -1,189 +1,212 @@
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import Tooltip from "../Tooltip";
|
|
||||||
import { decodeTime } from "ulid";
|
|
||||||
import { Text } from "preact-i18n";
|
|
||||||
import styled, { css } from "styled-components";
|
import styled, { css } from "styled-components";
|
||||||
|
import { decodeTime } from "ulid";
|
||||||
|
|
||||||
|
import { Text } from "preact-i18n";
|
||||||
|
|
||||||
import { MessageObject } from "../../../context/revoltjs/util";
|
import { MessageObject } from "../../../context/revoltjs/util";
|
||||||
|
|
||||||
|
import Tooltip from "../Tooltip";
|
||||||
|
|
||||||
export interface BaseMessageProps {
|
export interface BaseMessageProps {
|
||||||
head?: boolean,
|
head?: boolean;
|
||||||
failed?: boolean,
|
failed?: boolean;
|
||||||
mention?: boolean,
|
mention?: boolean;
|
||||||
blocked?: boolean,
|
blocked?: boolean;
|
||||||
sending?: boolean,
|
sending?: boolean;
|
||||||
contrast?: boolean
|
contrast?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default styled.div<BaseMessageProps>`
|
export default styled.div<BaseMessageProps>`
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow-x: none;
|
overflow-x: none;
|
||||||
padding: .125rem;
|
padding: 0.125rem;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
padding-right: 16px;
|
padding-right: 16px;
|
||||||
|
|
||||||
${ props => props.contrast && css`
|
${(props) =>
|
||||||
padding: .3rem;
|
props.contrast &&
|
||||||
border-radius: 4px;
|
css`
|
||||||
background: var(--hover);
|
padding: 0.3rem;
|
||||||
` }
|
border-radius: 4px;
|
||||||
|
background: var(--hover);
|
||||||
|
`}
|
||||||
|
|
||||||
${ props => props.head && css`
|
${(props) =>
|
||||||
margin-top: 12px;
|
props.head &&
|
||||||
` }
|
css`
|
||||||
|
margin-top: 12px;
|
||||||
|
`}
|
||||||
|
|
||||||
${ props => props.mention && css`
|
${(props) =>
|
||||||
background: var(--mention);
|
props.mention &&
|
||||||
` }
|
css`
|
||||||
|
background: var(--mention);
|
||||||
|
`}
|
||||||
|
|
||||||
${ props => props.blocked && css`
|
${(props) =>
|
||||||
filter: blur(4px);
|
props.blocked &&
|
||||||
transition: 0.2s ease filter;
|
css`
|
||||||
|
filter: blur(4px);
|
||||||
|
transition: 0.2s ease filter;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
filter: none;
|
filter: none;
|
||||||
}
|
}
|
||||||
` }
|
`}
|
||||||
|
|
||||||
${ props => props.sending && css`
|
${(props) =>
|
||||||
opacity: 0.8;
|
props.sending &&
|
||||||
color: var(--tertiary-foreground);
|
css`
|
||||||
` }
|
opacity: 0.8;
|
||||||
|
color: var(--tertiary-foreground);
|
||||||
|
`}
|
||||||
|
|
||||||
${ props => props.failed && css`
|
${(props) =>
|
||||||
color: var(--error);
|
props.failed &&
|
||||||
` }
|
css`
|
||||||
|
color: var(--error);
|
||||||
|
`}
|
||||||
|
|
||||||
.detail {
|
.detail {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.author {
|
.author {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 600 !important;
|
font-weight: 600 !important;
|
||||||
|
|
||||||
&:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.copy {
|
|
||||||
display: block;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--hover);
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
time {
|
.copy {
|
||||||
opacity: 1;
|
display: block;
|
||||||
}
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--hover);
|
||||||
|
|
||||||
|
time {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const MessageInfo = styled.div`
|
export const MessageInfo = styled.div`
|
||||||
width: 62px;
|
width: 62px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding-top: 2px;
|
padding-top: 2px;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
.copyBracket {
|
.copyBracket {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
|
|
||||||
.copyTime {
|
.copyTime {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
user-select: none;
|
user-select: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
transform: translateY(1px);
|
transform: translateY(1px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
time {
|
time {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
time, .edited {
|
time,
|
||||||
margin-top: 1px;
|
.edited {
|
||||||
cursor: default;
|
margin-top: 1px;
|
||||||
display: inline;
|
cursor: default;
|
||||||
font-size: 10px;
|
display: inline;
|
||||||
color: var(--tertiary-foreground);
|
font-size: 10px;
|
||||||
}
|
color: var(--tertiary-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
time, .edited > div {
|
time,
|
||||||
&::selection {
|
.edited > div {
|
||||||
background-color: transparent;
|
&::selection {
|
||||||
color: var(--tertiary-foreground);
|
background-color: transparent;
|
||||||
}
|
color: var(--tertiary-foreground);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const MessageContent = styled.div`
|
export const MessageContent = styled.div`
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
// overflow: hidden;
|
// overflow: hidden;
|
||||||
font-size: .875rem;
|
font-size: 0.875rem;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const DetailBase = styled.div`
|
export const DetailBase = styled.div`
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
color: var(--tertiary-foreground);
|
color: var(--tertiary-foreground);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export function MessageDetail({ message, position }: { message: MessageObject, position: 'left' | 'top' }) {
|
export function MessageDetail({
|
||||||
if (position === 'left') {
|
message,
|
||||||
if (message.edited) {
|
position,
|
||||||
return (
|
}: {
|
||||||
<>
|
message: MessageObject;
|
||||||
<time className="copyTime">
|
position: "left" | "top";
|
||||||
<i className="copyBracket">[</i>
|
}) {
|
||||||
{dayjs(decodeTime(message._id)).format("H:mm")}
|
if (position === "left") {
|
||||||
<i className="copyBracket">]</i>
|
if (message.edited) {
|
||||||
</time>
|
return (
|
||||||
<span className="edited">
|
<>
|
||||||
<Tooltip content={dayjs(message.edited).format("LLLL")}>
|
<time className="copyTime">
|
||||||
<Text id="app.main.channel.edited" />
|
<i className="copyBracket">[</i>
|
||||||
</Tooltip>
|
{dayjs(decodeTime(message._id)).format("H:mm")}
|
||||||
</span>
|
<i className="copyBracket">]</i>
|
||||||
</>
|
</time>
|
||||||
)
|
<span className="edited">
|
||||||
} else {
|
<Tooltip content={dayjs(message.edited).format("LLLL")}>
|
||||||
return (
|
<Text id="app.main.channel.edited" />
|
||||||
<>
|
</Tooltip>
|
||||||
<time>
|
</span>
|
||||||
<i className="copyBracket">[</i>
|
</>
|
||||||
{ dayjs(decodeTime(message._id)).format("H:mm") }
|
);
|
||||||
<i className="copyBracket">]</i>
|
} else {
|
||||||
</time>
|
return (
|
||||||
</>
|
<>
|
||||||
)
|
<time>
|
||||||
}
|
<i className="copyBracket">[</i>
|
||||||
}
|
{dayjs(decodeTime(message._id)).format("H:mm")}
|
||||||
|
<i className="copyBracket">]</i>
|
||||||
|
</time>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DetailBase>
|
<DetailBase>
|
||||||
<time>
|
<time>{dayjs(decodeTime(message._id)).calendar()}</time>
|
||||||
{dayjs(decodeTime(message._id)).calendar()}
|
{message.edited && (
|
||||||
</time>
|
<Tooltip content={dayjs(message.edited).format("LLLL")}>
|
||||||
{ message.edited && <Tooltip content={dayjs(message.edited).format("LLLL")}>
|
<Text id="app.main.channel.edited" />
|
||||||
<Text id="app.main.channel.edited" />
|
</Tooltip>
|
||||||
</Tooltip> }
|
)}
|
||||||
</DetailBase>
|
</DetailBase>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,388 +1,496 @@
|
||||||
import { ulid } from "ulid";
|
import { ShieldX } from "@styled-icons/boxicons-regular";
|
||||||
import { Text } from "preact-i18n";
|
import { Send } from "@styled-icons/boxicons-solid";
|
||||||
import { Channel } from "revolt.js";
|
|
||||||
import styled from "styled-components";
|
|
||||||
import { dispatch } from "../../../redux";
|
|
||||||
import { defer } from "../../../lib/defer";
|
|
||||||
import IconButton from "../../ui/IconButton";
|
|
||||||
import { PermissionTooltip } from "../Tooltip";
|
|
||||||
import { Send } from '@styled-icons/boxicons-solid';
|
|
||||||
import { debounce } from "../../../lib/debounce";
|
|
||||||
import Axios, { CancelTokenSource } from "axios";
|
import Axios, { CancelTokenSource } from "axios";
|
||||||
import { useTranslation } from "../../../lib/i18n";
|
import { Channel } from "revolt.js";
|
||||||
import { Reply } from "../../../redux/reducers/queue";
|
|
||||||
import { connectState } from "../../../redux/connector";
|
|
||||||
import { SoundContext } from "../../../context/Settings";
|
|
||||||
import { takeError } from "../../../context/revoltjs/util";
|
|
||||||
import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
|
|
||||||
import AutoComplete, { useAutoComplete } from "../AutoComplete";
|
|
||||||
import { ChannelPermission } from "revolt.js/dist/api/permissions";
|
import { ChannelPermission } from "revolt.js/dist/api/permissions";
|
||||||
|
import styled from "styled-components";
|
||||||
|
import { ulid } from "ulid";
|
||||||
|
|
||||||
|
import { Text } from "preact-i18n";
|
||||||
|
import { useCallback, useContext, useEffect, useState } from "preact/hooks";
|
||||||
|
|
||||||
|
import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
|
||||||
|
import { debounce } from "../../../lib/debounce";
|
||||||
|
import { defer } from "../../../lib/defer";
|
||||||
|
import { internalEmit, internalSubscribe } from "../../../lib/eventEmitter";
|
||||||
|
import { useTranslation } from "../../../lib/i18n";
|
||||||
|
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
|
||||||
|
import {
|
||||||
|
SingletonMessageRenderer,
|
||||||
|
SMOOTH_SCROLL_ON_RECEIVE,
|
||||||
|
} from "../../../lib/renderer/Singleton";
|
||||||
|
|
||||||
|
import { dispatch } from "../../../redux";
|
||||||
|
import { connectState } from "../../../redux/connector";
|
||||||
|
import { Reply } from "../../../redux/reducers/queue";
|
||||||
|
|
||||||
|
import { SoundContext } from "../../../context/Settings";
|
||||||
|
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
||||||
|
import {
|
||||||
|
FileUploader,
|
||||||
|
grabFiles,
|
||||||
|
uploadFile,
|
||||||
|
} from "../../../context/revoltjs/FileUploads";
|
||||||
import { AppContext } from "../../../context/revoltjs/RevoltClient";
|
import { AppContext } from "../../../context/revoltjs/RevoltClient";
|
||||||
import { useChannelPermission } from "../../../context/revoltjs/hooks";
|
import { useChannelPermission } from "../../../context/revoltjs/hooks";
|
||||||
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
|
import { takeError } from "../../../context/revoltjs/util";
|
||||||
import { internalEmit, internalSubscribe } from "../../../lib/eventEmitter";
|
|
||||||
import { useCallback, useContext, useEffect, useState } from "preact/hooks";
|
|
||||||
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
|
||||||
import { FileUploader, grabFiles, uploadFile } from "../../../context/revoltjs/FileUploads";
|
|
||||||
import { SingletonMessageRenderer, SMOOTH_SCROLL_ON_RECEIVE } from "../../../lib/renderer/Singleton";
|
|
||||||
import { ShieldX } from "@styled-icons/boxicons-regular";
|
|
||||||
|
|
||||||
|
import IconButton from "../../ui/IconButton";
|
||||||
|
|
||||||
|
import AutoComplete, { useAutoComplete } from "../AutoComplete";
|
||||||
|
import { PermissionTooltip } from "../Tooltip";
|
||||||
|
import FilePreview from "./bars/FilePreview";
|
||||||
import ReplyBar from "./bars/ReplyBar";
|
import ReplyBar from "./bars/ReplyBar";
|
||||||
import FilePreview from './bars/FilePreview';
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
channel: Channel;
|
channel: Channel;
|
||||||
draft?: string;
|
draft?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type UploadState =
|
export type UploadState =
|
||||||
| { type: "none" }
|
| { type: "none" }
|
||||||
| { type: "attached"; files: File[] }
|
| { type: "attached"; files: File[] }
|
||||||
| { type: "uploading"; files: File[]; percent: number; cancel: CancelTokenSource }
|
| {
|
||||||
| { type: "sending"; files: File[] }
|
type: "uploading";
|
||||||
| { type: "failed"; files: File[]; error: string };
|
files: File[];
|
||||||
|
percent: number;
|
||||||
|
cancel: CancelTokenSource;
|
||||||
|
}
|
||||||
|
| { type: "sending"; files: File[] }
|
||||||
|
| { type: "failed"; files: File[]; error: string };
|
||||||
|
|
||||||
const Base = styled.div`
|
const Base = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
background: var(--message-box);
|
background: var(--message-box);
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
font-size: .875rem;
|
font-size: 0.875rem;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Blocked = styled.div`
|
const Blocked = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 14px 0;
|
padding: 14px 0;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
font-size: .875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--tertiary-foreground);
|
color: var(--tertiary-foreground);
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-inline-end: 10px;
|
margin-inline-end: 10px;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Action = styled.div`
|
const Action = styled.div`
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// ! FIXME: add to app config and load from app config
|
// ! FIXME: add to app config and load from app config
|
||||||
export const CAN_UPLOAD_AT_ONCE = 5;
|
export const CAN_UPLOAD_AT_ONCE = 5;
|
||||||
|
|
||||||
function MessageBox({ channel, draft }: Props) {
|
function MessageBox({ channel, draft }: Props) {
|
||||||
const [ uploadState, setUploadState ] = useState<UploadState>({ type: 'none' });
|
const [uploadState, setUploadState] = useState<UploadState>({
|
||||||
const [ typing, setTyping ] = useState<boolean | number>(false);
|
type: "none",
|
||||||
const [ replies, setReplies ] = useState<Reply[]>([]);
|
});
|
||||||
const playSound = useContext(SoundContext);
|
const [typing, setTyping] = useState<boolean | number>(false);
|
||||||
const { openScreen } = useIntermediate();
|
const [replies, setReplies] = useState<Reply[]>([]);
|
||||||
const client = useContext(AppContext);
|
const playSound = useContext(SoundContext);
|
||||||
const translate = useTranslation();
|
const { openScreen } = useIntermediate();
|
||||||
|
const client = useContext(AppContext);
|
||||||
|
const translate = useTranslation();
|
||||||
|
|
||||||
const permissions = useChannelPermission(channel._id);
|
const permissions = useChannelPermission(channel._id);
|
||||||
if (!(permissions & ChannelPermission.SendMessage)) {
|
if (!(permissions & ChannelPermission.SendMessage)) {
|
||||||
return (
|
return (
|
||||||
<Base>
|
<Base>
|
||||||
<Blocked>
|
<Blocked>
|
||||||
<PermissionTooltip permission="SendMessages" placement="top">
|
<PermissionTooltip
|
||||||
<ShieldX size={22}/>
|
permission="SendMessages"
|
||||||
</PermissionTooltip>
|
placement="top">
|
||||||
<Text id="app.main.channel.misc.no_sending" />
|
<ShieldX size={22} />
|
||||||
</Blocked>
|
</PermissionTooltip>
|
||||||
</Base>
|
<Text id="app.main.channel.misc.no_sending" />
|
||||||
)
|
</Blocked>
|
||||||
}
|
</Base>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function setMessage(content?: string) {
|
function setMessage(content?: string) {
|
||||||
if (content) {
|
if (content) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "SET_DRAFT",
|
type: "SET_DRAFT",
|
||||||
channel: channel._id,
|
channel: channel._id,
|
||||||
content
|
content,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "CLEAR_DRAFT",
|
type: "CLEAR_DRAFT",
|
||||||
channel: channel._id
|
channel: channel._id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function append(content: string, action: 'quote' | 'mention') {
|
function append(content: string, action: "quote" | "mention") {
|
||||||
const text =
|
const text =
|
||||||
action === "quote"
|
action === "quote"
|
||||||
? `${content
|
? `${content
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map(x => `> ${x}`)
|
.map((x) => `> ${x}`)
|
||||||
.join("\n")}\n\n`
|
.join("\n")}\n\n`
|
||||||
: `${content} `;
|
: `${content} `;
|
||||||
|
|
||||||
if (!draft || draft.length === 0) {
|
if (!draft || draft.length === 0) {
|
||||||
setMessage(text);
|
setMessage(text);
|
||||||
} else {
|
} else {
|
||||||
setMessage(`${draft}\n${text}`);
|
setMessage(`${draft}\n${text}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return internalSubscribe("MessageBox", "append", append);
|
return internalSubscribe("MessageBox", "append", append);
|
||||||
}, [ draft ]);
|
}, [draft]);
|
||||||
|
|
||||||
async function send() {
|
async function send() {
|
||||||
if (uploadState.type === 'uploading' || uploadState.type === 'sending') return;
|
if (uploadState.type === "uploading" || uploadState.type === "sending")
|
||||||
|
return;
|
||||||
const content = draft?.trim() ?? '';
|
|
||||||
if (uploadState.type === 'attached') return sendFile(content);
|
|
||||||
if (content.length === 0) return;
|
|
||||||
|
|
||||||
stopTyping();
|
|
||||||
setMessage();
|
|
||||||
setReplies([]);
|
|
||||||
playSound('outbound');
|
|
||||||
|
|
||||||
const nonce = ulid();
|
const content = draft?.trim() ?? "";
|
||||||
dispatch({
|
if (uploadState.type === "attached") return sendFile(content);
|
||||||
type: "QUEUE_ADD",
|
if (content.length === 0) return;
|
||||||
nonce,
|
|
||||||
channel: channel._id,
|
|
||||||
message: {
|
|
||||||
_id: nonce,
|
|
||||||
channel: channel._id,
|
|
||||||
author: client.user!._id,
|
|
||||||
|
|
||||||
content,
|
|
||||||
replies
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
defer(() => SingletonMessageRenderer.jumpToBottom(channel._id, SMOOTH_SCROLL_ON_RECEIVE));
|
stopTyping();
|
||||||
|
setMessage();
|
||||||
|
setReplies([]);
|
||||||
|
playSound("outbound");
|
||||||
|
|
||||||
try {
|
const nonce = ulid();
|
||||||
await client.channels.sendMessage(channel._id, {
|
dispatch({
|
||||||
content,
|
type: "QUEUE_ADD",
|
||||||
nonce,
|
nonce,
|
||||||
replies
|
channel: channel._id,
|
||||||
});
|
message: {
|
||||||
} catch (error) {
|
_id: nonce,
|
||||||
dispatch({
|
channel: channel._id,
|
||||||
type: "QUEUE_FAIL",
|
author: client.user!._id,
|
||||||
error: takeError(error),
|
|
||||||
nonce
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendFile(content: string) {
|
content,
|
||||||
if (uploadState.type !== 'attached') return;
|
replies,
|
||||||
let attachments: string[] = [];
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const cancel = Axios.CancelToken.source();
|
defer(() =>
|
||||||
const files = uploadState.files;
|
SingletonMessageRenderer.jumpToBottom(
|
||||||
stopTyping();
|
channel._id,
|
||||||
setUploadState({ type: "uploading", files, percent: 0, cancel });
|
SMOOTH_SCROLL_ON_RECEIVE,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (let i=0;i<files.length&&i<CAN_UPLOAD_AT_ONCE;i++) {
|
await client.channels.sendMessage(channel._id, {
|
||||||
const file = files[i];
|
content,
|
||||||
attachments.push(
|
nonce,
|
||||||
await uploadFile(client.configuration!.features.autumn.url, 'attachments', file, {
|
replies,
|
||||||
onUploadProgress: e =>
|
});
|
||||||
setUploadState({
|
} catch (error) {
|
||||||
type: "uploading",
|
dispatch({
|
||||||
files,
|
type: "QUEUE_FAIL",
|
||||||
percent: Math.round(((i * 100) + (100 * e.loaded) / e.total) / Math.min(files.length, CAN_UPLOAD_AT_ONCE)),
|
error: takeError(error),
|
||||||
cancel
|
nonce,
|
||||||
}),
|
});
|
||||||
cancelToken: cancel.token
|
}
|
||||||
})
|
}
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
if (err?.message === "cancel") {
|
|
||||||
setUploadState({
|
|
||||||
type: "attached",
|
|
||||||
files
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
setUploadState({
|
|
||||||
type: "failed",
|
|
||||||
files,
|
|
||||||
error: takeError(err)
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
async function sendFile(content: string) {
|
||||||
}
|
if (uploadState.type !== "attached") return;
|
||||||
|
let attachments: string[] = [];
|
||||||
|
|
||||||
setUploadState({
|
const cancel = Axios.CancelToken.source();
|
||||||
type: "sending",
|
const files = uploadState.files;
|
||||||
files
|
stopTyping();
|
||||||
});
|
setUploadState({ type: "uploading", files, percent: 0, cancel });
|
||||||
|
|
||||||
const nonce = ulid();
|
try {
|
||||||
try {
|
for (let i = 0; i < files.length && i < CAN_UPLOAD_AT_ONCE; i++) {
|
||||||
await client.channels.sendMessage(channel._id, {
|
const file = files[i];
|
||||||
content,
|
attachments.push(
|
||||||
nonce,
|
await uploadFile(
|
||||||
replies,
|
client.configuration!.features.autumn.url,
|
||||||
attachments
|
"attachments",
|
||||||
});
|
file,
|
||||||
} catch (err) {
|
{
|
||||||
setUploadState({
|
onUploadProgress: (e) =>
|
||||||
type: "failed",
|
setUploadState({
|
||||||
files,
|
type: "uploading",
|
||||||
error: takeError(err)
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
setMessage();
|
setUploadState({
|
||||||
setReplies([]);
|
type: "sending",
|
||||||
playSound('outbound');
|
files,
|
||||||
|
});
|
||||||
|
|
||||||
if (files.length > CAN_UPLOAD_AT_ONCE) {
|
const nonce = ulid();
|
||||||
setUploadState({
|
try {
|
||||||
type: "attached",
|
await client.channels.sendMessage(channel._id, {
|
||||||
files: files.slice(CAN_UPLOAD_AT_ONCE)
|
content,
|
||||||
});
|
nonce,
|
||||||
} else {
|
replies,
|
||||||
setUploadState({ type: "none" });
|
attachments,
|
||||||
}
|
});
|
||||||
}
|
} catch (err) {
|
||||||
|
setUploadState({
|
||||||
function startTyping() {
|
type: "failed",
|
||||||
if (typeof typing === 'number' && + new Date() < typing) return;
|
files,
|
||||||
|
error: takeError(err),
|
||||||
|
});
|
||||||
|
|
||||||
const ws = client.websocket;
|
return;
|
||||||
if (ws.connected) {
|
}
|
||||||
setTyping(+ new Date() + 4000);
|
|
||||||
ws.send({
|
|
||||||
type: "BeginTyping",
|
|
||||||
channel: channel._id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function stopTyping(force?: boolean) {
|
setMessage();
|
||||||
if (force || typing) {
|
setReplies([]);
|
||||||
const ws = client.websocket;
|
playSound("outbound");
|
||||||
if (ws.connected) {
|
|
||||||
setTyping(false);
|
|
||||||
ws.send({
|
|
||||||
type: "EndTyping",
|
|
||||||
channel: channel._id
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const debouncedStopTyping = useCallback(debounce(stopTyping, 1000), [ channel._id ]);
|
if (files.length > CAN_UPLOAD_AT_ONCE) {
|
||||||
const { onChange, onKeyUp, onKeyDown, onFocus, onBlur, ...autoCompleteProps } =
|
setUploadState({
|
||||||
useAutoComplete(setMessage, {
|
type: "attached",
|
||||||
users: { type: 'channel', id: channel._id },
|
files: files.slice(CAN_UPLOAD_AT_ONCE),
|
||||||
channels: channel.channel_type === 'TextChannel' ? { server: channel.server } : undefined
|
});
|
||||||
});
|
} else {
|
||||||
|
setUploadState({ type: "none" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
function startTyping() {
|
||||||
<>
|
if (typeof typing === "number" && +new Date() < typing) 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'}
|
const ws = client.websocket;
|
||||||
uploading={uploadState.type === 'uploading' || uploadState.type === 'sending'}
|
if (ws.connected) {
|
||||||
|
setTyping(+new Date() + 4000);
|
||||||
|
ws.send({
|
||||||
|
type: "BeginTyping",
|
||||||
|
channel: channel._id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
remove={async () => setUploadState({ type: "none" })}
|
function stopTyping(force?: boolean) {
|
||||||
onChange={files => setUploadState({ type: "attached", files })}
|
if (force || typing) {
|
||||||
cancel={() => uploadState.type === 'uploading' && uploadState.cancel.cancel("cancel")}
|
const ws = client.websocket;
|
||||||
append={files => {
|
if (ws.connected) {
|
||||||
if (files.length === 0) return;
|
setTyping(false);
|
||||||
|
ws.send({
|
||||||
|
type: "EndTyping",
|
||||||
|
channel: channel._id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (uploadState.type === 'none') {
|
const debouncedStopTyping = useCallback(debounce(stopTyping, 1000), [
|
||||||
setUploadState({ type: 'attached', files });
|
channel._id,
|
||||||
} else if (uploadState.type === 'attached') {
|
]);
|
||||||
setUploadState({ type: 'attached', files: [ ...uploadState.files, ...files ] });
|
const {
|
||||||
}
|
onChange,
|
||||||
}}
|
onKeyUp,
|
||||||
/>
|
onKeyDown,
|
||||||
</Action> : undefined }
|
onFocus,
|
||||||
<TextAreaAutoSize
|
onBlur,
|
||||||
autoFocus
|
...autoCompleteProps
|
||||||
hideBorder
|
} = useAutoComplete(setMessage, {
|
||||||
maxRows={5}
|
users: { type: "channel", id: channel._id },
|
||||||
padding={14}
|
channels:
|
||||||
id="message"
|
channel.channel_type === "TextChannel"
|
||||||
value={draft ?? ''}
|
? { server: channel.server }
|
||||||
onKeyUp={onKeyUp}
|
: undefined,
|
||||||
onKeyDown={e => {
|
});
|
||||||
if (onKeyDown(e)) return;
|
|
||||||
|
|
||||||
if (
|
return (
|
||||||
e.key === "ArrowUp" &&
|
<>
|
||||||
(!draft || draft.length === 0)
|
<AutoComplete {...autoCompleteProps} />
|
||||||
) {
|
<FilePreview
|
||||||
e.preventDefault();
|
state={uploadState}
|
||||||
internalEmit("MessageRenderer", "edit_last");
|
addFile={() =>
|
||||||
return;
|
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 (!e.shiftKey && e.key === "Enter" && !isTouchscreenDevice) {
|
if (uploadState.type === "none") {
|
||||||
e.preventDefault();
|
setUploadState({ type: "attached", files });
|
||||||
return send();
|
} else if (uploadState.type === "attached") {
|
||||||
}
|
setUploadState({
|
||||||
|
type: "attached",
|
||||||
debouncedStopTyping(true);
|
files: [...uploadState.files, ...files],
|
||||||
}}
|
});
|
||||||
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")
|
</Action>
|
||||||
: translate("app.main.channel.message_where", { channel_name: channel.name })
|
) : undefined}
|
||||||
}
|
<TextAreaAutoSize
|
||||||
disabled={uploadState.type === 'uploading' || uploadState.type === 'sending'}
|
autoFocus
|
||||||
onChange={e => {
|
hideBorder
|
||||||
setMessage(e.currentTarget.value);
|
maxRows={5}
|
||||||
startTyping();
|
padding={14}
|
||||||
onChange(e);
|
id="message"
|
||||||
}}
|
value={draft ?? ""}
|
||||||
onFocus={onFocus}
|
onKeyUp={onKeyUp}
|
||||||
onBlur={onBlur} />
|
onKeyDown={(e) => {
|
||||||
{ isTouchscreenDevice && <Action>
|
if (onKeyDown(e)) return;
|
||||||
<IconButton onClick={send}>
|
|
||||||
<Send size={20} />
|
if (
|
||||||
</IconButton>
|
e.key === "ArrowUp" &&
|
||||||
</Action> }
|
(!draft || draft.length === 0)
|
||||||
</Base>
|
) {
|
||||||
</>
|
e.preventDefault();
|
||||||
)
|
internalEmit("MessageRenderer", "edit_last");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connectState<Omit<Props, "dispatch" | "draft">>(MessageBox, (state, { channel }) => {
|
export default connectState<Omit<Props, "dispatch" | "draft">>(
|
||||||
return {
|
MessageBox,
|
||||||
draft: state.drafts[channel._id]
|
(state, { channel }) => {
|
||||||
}
|
return {
|
||||||
}, true)
|
draft: state.drafts[channel._id],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
|
@ -1,149 +1,160 @@
|
||||||
import { User } from "revolt.js";
|
import { User } from "revolt.js";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import UserShort from "../user/UserShort";
|
|
||||||
import { TextReact } from "../../../lib/i18n";
|
|
||||||
import { attachContextMenu } from "preact-context-menu";
|
import { attachContextMenu } from "preact-context-menu";
|
||||||
import { MessageObject } from "../../../context/revoltjs/util";
|
|
||||||
import MessageBase, { MessageDetail, MessageInfo } from "./MessageBase";
|
import { TextReact } from "../../../lib/i18n";
|
||||||
|
|
||||||
import { useForceUpdate, useUser } from "../../../context/revoltjs/hooks";
|
import { useForceUpdate, useUser } from "../../../context/revoltjs/hooks";
|
||||||
|
import { MessageObject } from "../../../context/revoltjs/util";
|
||||||
|
|
||||||
|
import UserShort from "../user/UserShort";
|
||||||
|
import MessageBase, { MessageDetail, MessageInfo } from "./MessageBase";
|
||||||
|
|
||||||
const SystemContent = styled.div`
|
const SystemContent = styled.div`
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 2px 0;
|
padding: 2px 0;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
type SystemMessageParsed =
|
type SystemMessageParsed =
|
||||||
| { type: "text"; content: string }
|
| { type: "text"; content: string }
|
||||||
| { type: "user_added"; user: User; by: User }
|
| { type: "user_added"; user: User; by: User }
|
||||||
| { type: "user_remove"; user: User; by: User }
|
| { type: "user_remove"; user: User; by: User }
|
||||||
| { type: "user_joined"; user: User }
|
| { type: "user_joined"; user: User }
|
||||||
| { type: "user_left"; user: User }
|
| { type: "user_left"; user: User }
|
||||||
| { type: "user_kicked"; user: User }
|
| { type: "user_kicked"; user: User }
|
||||||
| { type: "user_banned"; user: User }
|
| { type: "user_banned"; user: User }
|
||||||
| { type: "channel_renamed"; name: string; by: User }
|
| { type: "channel_renamed"; name: string; by: User }
|
||||||
| { type: "channel_description_changed"; by: User }
|
| { type: "channel_description_changed"; by: User }
|
||||||
| { type: "channel_icon_changed"; by: User };
|
| { type: "channel_icon_changed"; by: User };
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
attachContext?: boolean;
|
attachContext?: boolean;
|
||||||
message: MessageObject;
|
message: MessageObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SystemMessage({ attachContext, message }: Props) {
|
export function SystemMessage({ attachContext, message }: Props) {
|
||||||
const ctx = useForceUpdate();
|
const ctx = useForceUpdate();
|
||||||
|
|
||||||
let data: SystemMessageParsed;
|
let data: SystemMessageParsed;
|
||||||
let content = message.content;
|
let content = message.content;
|
||||||
if (typeof content === "object") {
|
if (typeof content === "object") {
|
||||||
switch (content.type) {
|
switch (content.type) {
|
||||||
case "text":
|
case "text":
|
||||||
data = content;
|
data = content;
|
||||||
break;
|
break;
|
||||||
case "user_added":
|
case "user_added":
|
||||||
case "user_remove":
|
case "user_remove":
|
||||||
data = {
|
data = {
|
||||||
type: content.type,
|
type: content.type,
|
||||||
user: useUser(content.id, ctx) as User,
|
user: useUser(content.id, ctx) as User,
|
||||||
by: useUser(content.by, ctx) as User
|
by: useUser(content.by, ctx) as User,
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
case "user_joined":
|
case "user_joined":
|
||||||
case "user_left":
|
case "user_left":
|
||||||
case "user_kicked":
|
case "user_kicked":
|
||||||
case "user_banned":
|
case "user_banned":
|
||||||
data = {
|
data = {
|
||||||
type: content.type,
|
type: content.type,
|
||||||
user: useUser(content.id, ctx) as User
|
user: useUser(content.id, ctx) as User,
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
case "channel_renamed":
|
case "channel_renamed":
|
||||||
data = {
|
data = {
|
||||||
type: "channel_renamed",
|
type: "channel_renamed",
|
||||||
name: content.name,
|
name: content.name,
|
||||||
by: useUser(content.by, ctx) as User
|
by: useUser(content.by, ctx) as User,
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
case "channel_description_changed":
|
case "channel_description_changed":
|
||||||
case "channel_icon_changed":
|
case "channel_icon_changed":
|
||||||
data = {
|
data = {
|
||||||
type: content.type,
|
type: content.type,
|
||||||
by: useUser(content.by, ctx) as User
|
by: useUser(content.by, ctx) as User,
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
data = { type: "text", content: JSON.stringify(content) };
|
data = { type: "text", content: JSON.stringify(content) };
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
data = { type: "text", content };
|
data = { type: "text", content };
|
||||||
}
|
}
|
||||||
|
|
||||||
let children;
|
let children;
|
||||||
switch (data.type) {
|
switch (data.type) {
|
||||||
case "text":
|
case "text":
|
||||||
children = <span>{data.content}</span>;
|
children = <span>{data.content}</span>;
|
||||||
break;
|
break;
|
||||||
case "user_added":
|
case "user_added":
|
||||||
case "user_remove":
|
case "user_remove":
|
||||||
children = (
|
children = (
|
||||||
<TextReact
|
<TextReact
|
||||||
id={`app.main.channel.system.${data.type === 'user_added' ? "added_by" : "removed_by"}`}
|
id={`app.main.channel.system.${
|
||||||
fields={{
|
data.type === "user_added" ? "added_by" : "removed_by"
|
||||||
user: <UserShort user={data.user} />,
|
}`}
|
||||||
other_user: <UserShort user={data.by} />
|
fields={{
|
||||||
}}
|
user: <UserShort user={data.user} />,
|
||||||
/>
|
other_user: <UserShort user={data.by} />,
|
||||||
);
|
}}
|
||||||
break;
|
/>
|
||||||
case "user_joined":
|
);
|
||||||
case "user_left":
|
break;
|
||||||
case "user_kicked":
|
case "user_joined":
|
||||||
case "user_banned":
|
case "user_left":
|
||||||
children = (
|
case "user_kicked":
|
||||||
<TextReact
|
case "user_banned":
|
||||||
id={`app.main.channel.system.${data.type}`}
|
children = (
|
||||||
fields={{
|
<TextReact
|
||||||
user: <UserShort user={data.user} />
|
id={`app.main.channel.system.${data.type}`}
|
||||||
}}
|
fields={{
|
||||||
/>
|
user: <UserShort user={data.user} />,
|
||||||
);
|
}}
|
||||||
break;
|
/>
|
||||||
case "channel_renamed":
|
);
|
||||||
children = (
|
break;
|
||||||
<TextReact
|
case "channel_renamed":
|
||||||
id={`app.main.channel.system.channel_renamed`}
|
children = (
|
||||||
fields={{
|
<TextReact
|
||||||
user: <UserShort user={data.by} />,
|
id={`app.main.channel.system.channel_renamed`}
|
||||||
name: <b>{data.name}</b>
|
fields={{
|
||||||
}}
|
user: <UserShort user={data.by} />,
|
||||||
/>
|
name: <b>{data.name}</b>,
|
||||||
);
|
}}
|
||||||
break;
|
/>
|
||||||
case "channel_description_changed":
|
);
|
||||||
case "channel_icon_changed":
|
break;
|
||||||
children = (
|
case "channel_description_changed":
|
||||||
<TextReact
|
case "channel_icon_changed":
|
||||||
id={`app.main.channel.system.${data.type}`}
|
children = (
|
||||||
fields={{
|
<TextReact
|
||||||
user: <UserShort user={data.by} />
|
id={`app.main.channel.system.${data.type}`}
|
||||||
}}
|
fields={{
|
||||||
/>
|
user: <UserShort user={data.by} />,
|
||||||
);
|
}}
|
||||||
break;
|
/>
|
||||||
}
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageBase
|
<MessageBase
|
||||||
onContextMenu={attachContext ? attachContextMenu('Menu',
|
onContextMenu={
|
||||||
{ message, contextualChannel: message.channel }
|
attachContext
|
||||||
) : undefined}>
|
? attachContextMenu("Menu", {
|
||||||
<MessageInfo>
|
message,
|
||||||
<MessageDetail message={message} position="left" />
|
contextualChannel: message.channel,
|
||||||
</MessageInfo>
|
})
|
||||||
<SystemContent>{children}</SystemContent>
|
: undefined
|
||||||
</MessageBase>
|
}>
|
||||||
);
|
<MessageInfo>
|
||||||
|
<MessageDetail message={message} position="left" />
|
||||||
|
</MessageInfo>
|
||||||
|
<SystemContent>{children}</SystemContent>
|
||||||
|
</MessageBase>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,126 +1,133 @@
|
||||||
import TextFile from "./TextFile";
|
|
||||||
import { Text } from "preact-i18n";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import styles from "./Attachment.module.scss";
|
|
||||||
import AttachmentActions from "./AttachmentActions";
|
|
||||||
import { useContext, useState } from "preact/hooks";
|
|
||||||
import { AppContext } from "../../../../context/revoltjs/RevoltClient";
|
|
||||||
import { Attachment as AttachmentRJS } from "revolt.js/dist/api/objects";
|
import { Attachment as AttachmentRJS } from "revolt.js/dist/api/objects";
|
||||||
|
|
||||||
|
import styles from "./Attachment.module.scss";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { Text } from "preact-i18n";
|
||||||
|
import { useContext, useState } from "preact/hooks";
|
||||||
|
|
||||||
import { useIntermediate } from "../../../../context/intermediate/Intermediate";
|
import { useIntermediate } from "../../../../context/intermediate/Intermediate";
|
||||||
|
import { AppContext } from "../../../../context/revoltjs/RevoltClient";
|
||||||
|
|
||||||
import { MessageAreaWidthContext } from "../../../../pages/channels/messaging/MessageArea";
|
import { MessageAreaWidthContext } from "../../../../pages/channels/messaging/MessageArea";
|
||||||
|
import AttachmentActions from "./AttachmentActions";
|
||||||
|
import TextFile from "./TextFile";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
attachment: AttachmentRJS;
|
attachment: AttachmentRJS;
|
||||||
hasContent: boolean;
|
hasContent: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_ATTACHMENT_WIDTH = 480;
|
const MAX_ATTACHMENT_WIDTH = 480;
|
||||||
|
|
||||||
export default function Attachment({ attachment, hasContent }: Props) {
|
export default function Attachment({ attachment, hasContent }: Props) {
|
||||||
const client = useContext(AppContext);
|
const client = useContext(AppContext);
|
||||||
const { openScreen } = useIntermediate();
|
const { openScreen } = useIntermediate();
|
||||||
const { filename, metadata } = attachment;
|
const { filename, metadata } = attachment;
|
||||||
const [ spoiler, setSpoiler ] = useState(filename.startsWith("SPOILER_"));
|
const [spoiler, setSpoiler] = useState(filename.startsWith("SPOILER_"));
|
||||||
const [ loaded, setLoaded ] = useState(false)
|
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) {
|
||||||
switch (metadata.type) {
|
case "Image": {
|
||||||
case "Image": {
|
return (
|
||||||
return (
|
<div
|
||||||
<div
|
className={styles.container}
|
||||||
className={styles.container}
|
onClick={() => spoiler && setSpoiler(false)}>
|
||||||
onClick={() => spoiler && setSpoiler(false)}
|
{spoiler && (
|
||||||
>
|
<div className={styles.overflow}>
|
||||||
{spoiler && (
|
<span>
|
||||||
<div className={styles.overflow}>
|
<Text id="app.main.channel.misc.spoiler_attachment" />
|
||||||
<span><Text id="app.main.channel.misc.spoiler_attachment" /></span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<img
|
<img
|
||||||
src={url}
|
src={url}
|
||||||
alt={filename}
|
alt={filename}
|
||||||
width={metadata.width}
|
width={metadata.width}
|
||||||
height={metadata.height}
|
height={metadata.height}
|
||||||
data-spoiler={spoiler}
|
data-spoiler={spoiler}
|
||||||
data-has-content={hasContent}
|
data-has-content={hasContent}
|
||||||
className={classNames(styles.attachment, styles.image, loaded && styles.loaded)}
|
className={classNames(
|
||||||
onClick={() =>
|
styles.attachment,
|
||||||
openScreen({ id: "image_viewer", attachment })
|
styles.image,
|
||||||
}
|
loaded && styles.loaded,
|
||||||
onMouseDown={ev =>
|
)}
|
||||||
ev.button === 1 &&
|
onClick={() =>
|
||||||
window.open(url, "_blank")
|
openScreen({ id: "image_viewer", attachment })
|
||||||
}
|
}
|
||||||
onLoad={() => setLoaded(true)}
|
onMouseDown={(ev) =>
|
||||||
/>
|
ev.button === 1 && window.open(url, "_blank")
|
||||||
</div>
|
}
|
||||||
);
|
onLoad={() => setLoaded(true)}
|
||||||
}
|
/>
|
||||||
case "Audio": {
|
</div>
|
||||||
return (
|
);
|
||||||
<div
|
}
|
||||||
className={classNames(styles.attachment, styles.audio)}
|
case "Audio": {
|
||||||
data-has-content={hasContent}
|
return (
|
||||||
>
|
<div
|
||||||
<AttachmentActions attachment={attachment} />
|
className={classNames(styles.attachment, styles.audio)}
|
||||||
<audio src={url} controls />
|
data-has-content={hasContent}>
|
||||||
</div>
|
<AttachmentActions attachment={attachment} />
|
||||||
);
|
<audio src={url} controls />
|
||||||
}
|
</div>
|
||||||
case "Video": {
|
);
|
||||||
return (
|
}
|
||||||
<div
|
case "Video": {
|
||||||
className={styles.container}
|
return (
|
||||||
onClick={() => spoiler && setSpoiler(false)}>
|
<div
|
||||||
{spoiler && (
|
className={styles.container}
|
||||||
<div className={styles.overflow}>
|
onClick={() => spoiler && setSpoiler(false)}>
|
||||||
<span><Text id="app.main.channel.misc.spoiler_attachment" /></span>
|
{spoiler && (
|
||||||
</div>
|
<div className={styles.overflow}>
|
||||||
)}
|
<span>
|
||||||
<div
|
<Text id="app.main.channel.misc.spoiler_attachment" />
|
||||||
data-spoiler={spoiler}
|
</span>
|
||||||
data-has-content={hasContent}
|
</div>
|
||||||
className={classNames(styles.attachment, styles.video)}
|
)}
|
||||||
>
|
<div
|
||||||
<AttachmentActions attachment={attachment} />
|
data-spoiler={spoiler}
|
||||||
<video
|
data-has-content={hasContent}
|
||||||
src={url}
|
className={classNames(styles.attachment, styles.video)}>
|
||||||
width={metadata.width}
|
<AttachmentActions attachment={attachment} />
|
||||||
height={metadata.height}
|
<video
|
||||||
className={classNames(loaded && styles.loaded)}
|
src={url}
|
||||||
controls
|
width={metadata.width}
|
||||||
onMouseDown={ev =>
|
height={metadata.height}
|
||||||
ev.button === 1 &&
|
className={classNames(loaded && styles.loaded)}
|
||||||
window.open(url, "_blank")
|
controls
|
||||||
}
|
onMouseDown={(ev) =>
|
||||||
onLoadedMetadata={() => setLoaded(true)}
|
ev.button === 1 && window.open(url, "_blank")
|
||||||
/>
|
}
|
||||||
</div>
|
onLoadedMetadata={() => setLoaded(true)}
|
||||||
</div>
|
/>
|
||||||
);
|
</div>
|
||||||
}
|
</div>
|
||||||
case 'Text': {
|
);
|
||||||
return (
|
}
|
||||||
<div
|
case "Text": {
|
||||||
className={classNames(styles.attachment, styles.text)}
|
return (
|
||||||
data-has-content={hasContent}
|
<div
|
||||||
>
|
className={classNames(styles.attachment, styles.text)}
|
||||||
<TextFile attachment={attachment} />
|
data-has-content={hasContent}>
|
||||||
<AttachmentActions attachment={attachment} />
|
<TextFile attachment={attachment} />
|
||||||
</div>
|
<AttachmentActions attachment={attachment} />
|
||||||
);
|
</div>
|
||||||
}
|
);
|
||||||
default: {
|
}
|
||||||
return (
|
default: {
|
||||||
<div
|
return (
|
||||||
className={classNames(styles.attachment, styles.file)}
|
<div
|
||||||
data-has-content={hasContent}
|
className={classNames(styles.attachment, styles.file)}
|
||||||
>
|
data-has-content={hasContent}>
|
||||||
<AttachmentActions attachment={attachment} />
|
<AttachmentActions attachment={attachment} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,83 +1,115 @@
|
||||||
import { useContext } from 'preact/hooks';
|
import {
|
||||||
import styles from './Attachment.module.scss';
|
Download,
|
||||||
import IconButton from '../../../ui/IconButton';
|
LinkExternal,
|
||||||
|
File,
|
||||||
|
Headphone,
|
||||||
|
Video,
|
||||||
|
} from "@styled-icons/boxicons-regular";
|
||||||
import { Attachment } from "revolt.js/dist/api/objects";
|
import { Attachment } from "revolt.js/dist/api/objects";
|
||||||
import { determineFileSize } from '../../../../lib/fileSize';
|
|
||||||
import { AppContext } from '../../../../context/revoltjs/RevoltClient';
|
import styles from "./Attachment.module.scss";
|
||||||
import { Download, LinkExternal, File, Headphone, Video } from '@styled-icons/boxicons-regular';
|
import classNames from "classnames";
|
||||||
import classNames from 'classnames';
|
import { useContext } from "preact/hooks";
|
||||||
|
|
||||||
|
import { determineFileSize } from "../../../../lib/fileSize";
|
||||||
|
|
||||||
|
import { AppContext } from "../../../../context/revoltjs/RevoltClient";
|
||||||
|
|
||||||
|
import IconButton from "../../../ui/IconButton";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
attachment: Attachment;
|
attachment: Attachment;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AttachmentActions({ attachment }: Props) {
|
export default function AttachmentActions({ attachment }: Props) {
|
||||||
const client = useContext(AppContext);
|
const client = useContext(AppContext);
|
||||||
const { filename, metadata, size } = attachment;
|
const { filename, metadata, size } = attachment;
|
||||||
|
|
||||||
const url = client.generateFileURL(attachment)!;
|
const url = client.generateFileURL(attachment)!;
|
||||||
const open_url = `${url}/${filename}`;
|
const open_url = `${url}/${filename}`;
|
||||||
const download_url = url.replace('attachments', 'attachments/download')
|
const download_url = url.replace("attachments", "attachments/download");
|
||||||
|
|
||||||
// for some reason revolt.js says the size is a string even though it's a number
|
const filesize = determineFileSize(size);
|
||||||
const filesize = determineFileSize(size);
|
|
||||||
|
|
||||||
switch (metadata.type) {
|
switch (metadata.type) {
|
||||||
case 'Image':
|
case "Image":
|
||||||
return (
|
return (
|
||||||
<div className={classNames(styles.actions, styles.imageAction)}>
|
<div className={classNames(styles.actions, styles.imageAction)}>
|
||||||
<span className={styles.filename}>{filename}</span>
|
<span className={styles.filename}>{filename}</span>
|
||||||
<span className={styles.filesize}>{metadata.width + 'x' + metadata.height} ({filesize})</span>
|
<span className={styles.filesize}>
|
||||||
<a href={open_url} target="_blank" className={styles.iconType} >
|
{metadata.width + "x" + metadata.height} ({filesize})
|
||||||
<IconButton>
|
</span>
|
||||||
<LinkExternal size={24} />
|
<a
|
||||||
</IconButton>
|
href={open_url}
|
||||||
</a>
|
target="_blank"
|
||||||
<a href={download_url} className={styles.downloadIcon} download target="_blank">
|
className={styles.iconType}>
|
||||||
<IconButton>
|
<IconButton>
|
||||||
<Download size={24} />
|
<LinkExternal size={24} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
<a
|
||||||
)
|
href={download_url}
|
||||||
case 'Audio':
|
className={styles.downloadIcon}
|
||||||
return (
|
download
|
||||||
<div className={classNames(styles.actions, styles.audioAction)}>
|
target="_blank">
|
||||||
<Headphone size={24} className={styles.iconType} />
|
<IconButton>
|
||||||
<span className={styles.filename}>{filename}</span>
|
<Download size={24} />
|
||||||
<span className={styles.filesize}>{filesize}</span>
|
</IconButton>
|
||||||
<a href={download_url} className={styles.downloadIcon} download target="_blank">
|
</a>
|
||||||
<IconButton>
|
</div>
|
||||||
<Download size={24} />
|
);
|
||||||
</IconButton>
|
case "Audio":
|
||||||
</a>
|
return (
|
||||||
</div>
|
<div className={classNames(styles.actions, styles.audioAction)}>
|
||||||
)
|
<Headphone size={24} className={styles.iconType} />
|
||||||
case 'Video':
|
<span className={styles.filename}>{filename}</span>
|
||||||
return (
|
<span className={styles.filesize}>{filesize}</span>
|
||||||
<div className={classNames(styles.actions, styles.videoAction)}>
|
<a
|
||||||
<Video size={24} className={styles.iconType} />
|
href={download_url}
|
||||||
<span className={styles.filename}>{filename}</span>
|
className={styles.downloadIcon}
|
||||||
<span className={styles.filesize}>{metadata.width + 'x' + metadata.height} ({filesize})</span>
|
download
|
||||||
<a href={download_url} className={styles.downloadIcon} download target="_blank">
|
target="_blank">
|
||||||
<IconButton>
|
<IconButton>
|
||||||
<Download size={24} />
|
<Download size={24} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
default:
|
case "Video":
|
||||||
return (
|
return (
|
||||||
<div className={styles.actions}>
|
<div className={classNames(styles.actions, styles.videoAction)}>
|
||||||
<File size={24} className={styles.iconType} />
|
<Video size={24} className={styles.iconType} />
|
||||||
<span className={styles.filename}>{filename}</span>
|
<span className={styles.filename}>{filename}</span>
|
||||||
<span className={styles.filesize}>{filesize}</span>
|
<span className={styles.filesize}>
|
||||||
<a href={download_url} className={styles.downloadIcon} download target="_blank">
|
{metadata.width + "x" + metadata.height} ({filesize})
|
||||||
<IconButton>
|
</span>
|
||||||
<Download size={24} />
|
<a
|
||||||
</IconButton>
|
href={download_url}
|
||||||
</a>
|
className={styles.downloadIcon}
|
||||||
</div>
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,72 +1,93 @@
|
||||||
import { Text } from "preact-i18n";
|
|
||||||
import UserShort from "../../user/UserShort";
|
|
||||||
import styled, { css } from "styled-components";
|
|
||||||
import Markdown from "../../../markdown/Markdown";
|
|
||||||
import { Reply, File } from "@styled-icons/boxicons-regular";
|
import { Reply, File } from "@styled-icons/boxicons-regular";
|
||||||
import { useUser } from "../../../../context/revoltjs/hooks";
|
import styled, { css } from "styled-components";
|
||||||
|
|
||||||
|
import { Text } from "preact-i18n";
|
||||||
|
|
||||||
import { useRenderState } from "../../../../lib/renderer/Singleton";
|
import { useRenderState } from "../../../../lib/renderer/Singleton";
|
||||||
|
|
||||||
|
import { useUser } from "../../../../context/revoltjs/hooks";
|
||||||
|
|
||||||
|
import Markdown from "../../../markdown/Markdown";
|
||||||
|
import UserShort from "../../user/UserShort";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
channel: string
|
channel: string;
|
||||||
index: number
|
index: number;
|
||||||
id: string
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ReplyBase = styled.div<{ head?: boolean, fail?: boolean, preview?: boolean }>`
|
export const ReplyBase = styled.div<{
|
||||||
gap: 4px;
|
head?: boolean;
|
||||||
display: flex;
|
fail?: boolean;
|
||||||
font-size: 0.8em;
|
preview?: boolean;
|
||||||
margin-left: 30px;
|
}>`
|
||||||
user-select: none;
|
gap: 4px;
|
||||||
margin-bottom: 4px;
|
display: flex;
|
||||||
align-items: center;
|
font-size: 0.8em;
|
||||||
color: var(--secondary-foreground);
|
margin-left: 30px;
|
||||||
|
user-select: none;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
align-items: center;
|
||||||
|
color: var(--secondary-foreground);
|
||||||
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
svg:first-child {
|
svg:first-child {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transform: scaleX(-1);
|
transform: scaleX(-1);
|
||||||
color: var(--tertiary-foreground);
|
color: var(--tertiary-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
${ props => props.fail && css`
|
${(props) =>
|
||||||
color: var(--tertiary-foreground);
|
props.fail &&
|
||||||
` }
|
css`
|
||||||
|
color: var(--tertiary-foreground);
|
||||||
|
`}
|
||||||
|
|
||||||
${ props => props.head && css`
|
${(props) =>
|
||||||
margin-top: 12px;
|
props.head &&
|
||||||
` }
|
css`
|
||||||
|
margin-top: 12px;
|
||||||
|
`}
|
||||||
|
|
||||||
${ props => props.preview && css`
|
${(props) =>
|
||||||
margin-left: 0;
|
props.preview &&
|
||||||
` }
|
css`
|
||||||
|
margin-left: 0;
|
||||||
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export function MessageReply({ index, channel, id }: Props) {
|
export function MessageReply({ index, channel, id }: Props) {
|
||||||
const view = useRenderState(channel);
|
const view = useRenderState(channel);
|
||||||
if (view?.type !== 'RENDER') return null;
|
if (view?.type !== "RENDER") return null;
|
||||||
|
|
||||||
const message = view.messages.find(x => x._id === id);
|
const message = view.messages.find((x) => x._id === id);
|
||||||
if (!message) {
|
if (!message) {
|
||||||
return (
|
return (
|
||||||
<ReplyBase head={index === 0} fail>
|
<ReplyBase head={index === 0} fail>
|
||||||
<Reply size={16} />
|
<Reply size={16} />
|
||||||
<span><Text id="app.main.channel.misc.failed_load" /></span>
|
<span>
|
||||||
</ReplyBase>
|
<Text id="app.main.channel.misc.failed_load" />
|
||||||
)
|
</span>
|
||||||
}
|
</ReplyBase>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const user = useUser(message.author);
|
const user = useUser(message.author);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReplyBase head={index === 0}>
|
<ReplyBase head={index === 0}>
|
||||||
<Reply size={16} />
|
<Reply size={16} />
|
||||||
<UserShort user={user} size={16} />
|
<UserShort user={user} size={16} />
|
||||||
{ message.attachments && message.attachments.length > 0 && <File size={16} /> }
|
{message.attachments && message.attachments.length > 0 && (
|
||||||
<Markdown disallowBigEmoji content={(message.content as string).replace(/\n/g, ' ')} />
|
<File size={16} />
|
||||||
</ReplyBase>
|
)}
|
||||||
)
|
<Markdown
|
||||||
|
disallowBigEmoji
|
||||||
|
content={(message.content as string).replace(/\n/g, " ")}
|
||||||
|
/>
|
||||||
|
</ReplyBase>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,57 +1,72 @@
|
||||||
import axios from 'axios';
|
import axios from "axios";
|
||||||
import Preloader from '../../../ui/Preloader';
|
import { Attachment } from "revolt.js/dist/api/objects";
|
||||||
import styles from './Attachment.module.scss';
|
|
||||||
import { Attachment } from 'revolt.js/dist/api/objects';
|
import styles from "./Attachment.module.scss";
|
||||||
import { useContext, useEffect, useState } from 'preact/hooks';
|
import { useContext, useEffect, useState } from "preact/hooks";
|
||||||
import RequiresOnline from '../../../../context/revoltjs/RequiresOnline';
|
|
||||||
import { AppContext, StatusContext } from '../../../../context/revoltjs/RevoltClient';
|
import RequiresOnline from "../../../../context/revoltjs/RequiresOnline";
|
||||||
|
import {
|
||||||
|
AppContext,
|
||||||
|
StatusContext,
|
||||||
|
} from "../../../../context/revoltjs/RevoltClient";
|
||||||
|
|
||||||
|
import Preloader from "../../../ui/Preloader";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
attachment: Attachment;
|
attachment: Attachment;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileCache: { [key: string]: string } = {};
|
const fileCache: { [key: string]: string } = {};
|
||||||
|
|
||||||
export default function TextFile({ attachment }: Props) {
|
export default function TextFile({ attachment }: Props) {
|
||||||
const [ content, setContent ] = useState<undefined | string>(undefined);
|
const [content, setContent] = useState<undefined | string>(undefined);
|
||||||
const [ loading, setLoading ] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const status = useContext(StatusContext);
|
const status = useContext(StatusContext);
|
||||||
const client = useContext(AppContext);
|
const client = useContext(AppContext);
|
||||||
|
|
||||||
const url = client.generateFileURL(attachment)!;
|
const url = client.generateFileURL(attachment)!;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof content !== 'undefined') return;
|
if (typeof content !== "undefined") return;
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
let cached = fileCache[attachment._id];
|
let cached = fileCache[attachment._id];
|
||||||
if (cached) {
|
if (cached) {
|
||||||
setContent(cached);
|
setContent(cached);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} else {
|
} else {
|
||||||
axios.get(url)
|
axios
|
||||||
.then(res => {
|
.get(url)
|
||||||
setContent(res.data);
|
.then((res) => {
|
||||||
fileCache[attachment._id] = res.data;
|
setContent(res.data);
|
||||||
setLoading(false);
|
fileCache[attachment._id] = res.data;
|
||||||
})
|
setLoading(false);
|
||||||
.catch(() => {
|
})
|
||||||
console.error("Failed to load text file. [", attachment._id, "]");
|
.catch(() => {
|
||||||
setLoading(false)
|
console.error(
|
||||||
})
|
"Failed to load text file. [",
|
||||||
}
|
attachment._id,
|
||||||
}, [ content, loading, status ]);
|
"]",
|
||||||
|
);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [content, loading, status]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.textContent} data-loading={typeof content === 'undefined'}>
|
<div
|
||||||
{
|
className={styles.textContent}
|
||||||
content ?
|
data-loading={typeof content === "undefined"}>
|
||||||
<pre><code>{ content }</code></pre>
|
{content ? (
|
||||||
: <RequiresOnline>
|
<pre>
|
||||||
<Preloader type="ring" />
|
<code>{content}</code>
|
||||||
</RequiresOnline>
|
</pre>
|
||||||
}
|
) : (
|
||||||
</div>
|
<RequiresOnline>
|
||||||
)
|
<Preloader type="ring" />
|
||||||
|
</RequiresOnline>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,194 +1,233 @@
|
||||||
import { Text } from "preact-i18n";
|
|
||||||
import styled from "styled-components";
|
|
||||||
import { CAN_UPLOAD_AT_ONCE, UploadState } from "../MessageBox";
|
|
||||||
import { useEffect, useState } from 'preact/hooks';
|
|
||||||
import { determineFileSize } from '../../../../lib/fileSize';
|
|
||||||
import { XCircle, Plus, Share, X, File } from "@styled-icons/boxicons-regular";
|
import { XCircle, Plus, Share, X, File } from "@styled-icons/boxicons-regular";
|
||||||
|
import styled from "styled-components";
|
||||||
|
|
||||||
|
import { Text } from "preact-i18n";
|
||||||
|
import { useEffect, useState } from "preact/hooks";
|
||||||
|
|
||||||
|
import { determineFileSize } from "../../../../lib/fileSize";
|
||||||
|
|
||||||
|
import { CAN_UPLOAD_AT_ONCE, UploadState } from "../MessageBox";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
state: UploadState,
|
state: UploadState;
|
||||||
addFile: () => void,
|
addFile: () => void;
|
||||||
removeFile: (index: number) => void
|
removeFile: (index: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Container = styled.div`
|
const Container = styled.div`
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--message-box);
|
background: var(--message-box);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Carousel = styled.div`
|
const Carousel = styled.div`
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow-x: scroll;
|
overflow-x: scroll;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Entry = styled.div`
|
const Entry = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
&.fade {
|
&.fade {
|
||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
span.fn {
|
span.fn {
|
||||||
margin: auto;
|
margin: auto;
|
||||||
font-size: .8em;
|
font-size: 0.8em;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
max-width: 180px;
|
max-width: 180px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
color: var(--secondary-foreground);
|
color: var(--secondary-foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
span.size {
|
span.size {
|
||||||
font-size: .6em;
|
font-size: 0.6em;
|
||||||
color: var(--tertiary-foreground);
|
color: var(--tertiary-foreground);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Description = styled.div`
|
const Description = styled.div`
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
display: flex;
|
display: flex;
|
||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
color: var(--secondary-foreground);
|
color: var(--secondary-foreground);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Divider = styled.div`
|
const Divider = styled.div`
|
||||||
width: 4px;
|
width: 4px;
|
||||||
height: 130px;
|
height: 130px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background: var(--tertiary-background);
|
background: var(--tertiary-background);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const EmptyEntry = styled.div`
|
const EmptyEntry = styled.div`
|
||||||
width: 100px;
|
width: 100px;
|
||||||
height: 100px;
|
height: 100px;
|
||||||
display: grid;
|
display: grid;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
background: var(--primary-background);
|
background: var(--primary-background);
|
||||||
transition: 0.1s ease background-color;
|
transition: 0.1s ease background-color;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--secondary-background);
|
background: var(--secondary-background);
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const PreviewBox = styled.div`
|
const PreviewBox = styled.div`
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template: "main" 100px / minmax(100px, 1fr);
|
grid-template: "main" 100px / minmax(100px, 1fr);
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
|
|
||||||
background: var(--primary-background);
|
|
||||||
|
|
||||||
overflow: hidden;
|
background: var(--primary-background);
|
||||||
|
|
||||||
cursor: pointer;
|
|
||||||
border-radius: 4px;
|
|
||||||
|
|
||||||
.icon, .overlay { grid-area: main }
|
|
||||||
|
|
||||||
.icon {
|
overflow: hidden;
|
||||||
height: 100px;
|
|
||||||
width: 100%;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
object-fit: contain;
|
|
||||||
}
|
|
||||||
|
|
||||||
.overlay {
|
cursor: pointer;
|
||||||
display: grid;
|
border-radius: 4px;
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
|
|
||||||
width: 100%;
|
.icon,
|
||||||
height: 100%;
|
.overlay {
|
||||||
|
grid-area: main;
|
||||||
opacity: 0;
|
}
|
||||||
visibility: hidden;
|
|
||||||
|
|
||||||
transition: 0.1s ease opacity;
|
.icon {
|
||||||
}
|
height: 100px;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
.overlay {
|
||||||
.overlay {
|
display: grid;
|
||||||
visibility: visible;
|
align-items: center;
|
||||||
opacity: 1;
|
justify-content: center;
|
||||||
background-color: rgba(0, 0, 0, 0.8);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
function FileEntry({ file, remove, index }: { file: File, remove?: () => void, index: number }) {
|
width: 100%;
|
||||||
if (!file.type.startsWith('image/')) return (
|
height: 100%;
|
||||||
<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('');
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
|
||||||
useEffect(() => {
|
transition: 0.1s ease opacity;
|
||||||
let url: string = URL.createObjectURL(file);
|
}
|
||||||
setURL(url);
|
|
||||||
return () => URL.revokeObjectURL(url);
|
|
||||||
}, [ file ]);
|
|
||||||
|
|
||||||
return (
|
&:hover {
|
||||||
<Entry className={index >= CAN_UPLOAD_AT_ONCE ? 'fade' : ''}>
|
.overlay {
|
||||||
<PreviewBox onClick={remove}>
|
visibility: visible;
|
||||||
<img class="icon" src={url} alt={file.name} />
|
opacity: 1;
|
||||||
<div class="overlay"><XCircle size={36} /></div>
|
background-color: rgba(0, 0, 0, 0.8);
|
||||||
</PreviewBox>
|
}
|
||||||
<span class="fn">{file.name}</span>
|
}
|
||||||
<span class="size">{determineFileSize(file.size)}</span>
|
`;
|
||||||
</Entry>
|
|
||||||
)
|
function FileEntry({
|
||||||
|
file,
|
||||||
|
remove,
|
||||||
|
index,
|
||||||
|
}: {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
|
||||||
|
const [url, setURL] = useState("");
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FilePreview({ state, addFile, removeFile }: Props) {
|
export default function FilePreview({ state, addFile, removeFile }: Props) {
|
||||||
if (state.type === 'none') return null;
|
if (state.type === "none") return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container>
|
||||||
<Carousel>
|
<Carousel>
|
||||||
{ state.files.map((file, index) =>
|
{state.files.map((file, index) => (
|
||||||
<>
|
<>
|
||||||
{ index === CAN_UPLOAD_AT_ONCE && <Divider /> }
|
{index === CAN_UPLOAD_AT_ONCE && <Divider />}
|
||||||
<FileEntry index={index} file={file} key={file.name} remove={state.type === 'attached' ? () => removeFile(index) : undefined} />
|
<FileEntry
|
||||||
</>
|
index={index}
|
||||||
) }
|
file={file}
|
||||||
{ state.type === 'attached' && <EmptyEntry onClick={addFile}><Plus size={48} /></EmptyEntry> }
|
key={file.name}
|
||||||
</Carousel>
|
remove={
|
||||||
{ state.type === 'uploading' && <Description>
|
state.type === "attached"
|
||||||
<Share size={24} />
|
? () => removeFile(index)
|
||||||
<Text id="app.main.channel.uploading_file" /> ({state.percent}%)
|
: undefined
|
||||||
</Description> }
|
}
|
||||||
{ state.type === 'sending' && <Description>
|
/>
|
||||||
<Share size={24} />
|
</>
|
||||||
Sending...
|
))}
|
||||||
</Description> }
|
{state.type === "attached" && (
|
||||||
{ state.type === 'failed' && <Description>
|
<EmptyEntry onClick={addFile}>
|
||||||
<X size={24} />
|
<Plus size={48} />
|
||||||
<Text id={`error.${state.error}`} />
|
</EmptyEntry>
|
||||||
</Description> }
|
)}
|
||||||
</Container>
|
</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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,53 +1,64 @@
|
||||||
import { Text } from "preact-i18n";
|
|
||||||
import styled from "styled-components";
|
|
||||||
import { DownArrow } from "@styled-icons/boxicons-regular";
|
import { DownArrow } from "@styled-icons/boxicons-regular";
|
||||||
import { SingletonMessageRenderer, useRenderState } from "../../../../lib/renderer/Singleton";
|
import styled from "styled-components";
|
||||||
|
|
||||||
|
import { Text } from "preact-i18n";
|
||||||
|
|
||||||
|
import {
|
||||||
|
SingletonMessageRenderer,
|
||||||
|
useRenderState,
|
||||||
|
} from "../../../../lib/renderer/Singleton";
|
||||||
|
|
||||||
const Bar = styled.div`
|
const Bar = styled.div`
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
> div {
|
> div {
|
||||||
top: -26px;
|
top: -26px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 4px 4px 0 0;
|
border-radius: 4px 4px 0 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
color: var(--secondary-foreground);
|
color: var(--secondary-foreground);
|
||||||
background: var(--secondary-background);
|
background: var(--secondary-background);
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
transition: color ease-in-out .08s;
|
transition: color ease-in-out 0.08s;
|
||||||
|
|
||||||
> div {
|
> div {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--primary-text);
|
color: var(--primary-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
transform: translateY(1px);
|
transform: translateY(1px);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default function JumpToBottom({ id }: { id: string }) {
|
export default function JumpToBottom({ id }: { id: string }) {
|
||||||
const view = useRenderState(id);
|
const view = useRenderState(id);
|
||||||
if (!view || view.type !== 'RENDER' || view.atBottom) return null;
|
if (!view || view.type !== "RENDER" || view.atBottom) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Bar>
|
<Bar>
|
||||||
<div onClick={() => SingletonMessageRenderer.jumpToBottom(id, true)}>
|
<div
|
||||||
<div><Text id="app.main.channel.misc.viewing_old" /></div>
|
onClick={() => SingletonMessageRenderer.jumpToBottom(id, true)}>
|
||||||
<div><Text id="app.main.channel.misc.jump_present" /> <DownArrow size={18}/></div>
|
<div>
|
||||||
</div>
|
<Text id="app.main.channel.misc.viewing_old" />
|
||||||
</Bar>
|
</div>
|
||||||
)
|
<div>
|
||||||
|
<Text id="app.main.channel.misc.jump_present" />{" "}
|
||||||
|
<DownArrow size={18} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Bar>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,94 +1,141 @@
|
||||||
import { Text } from "preact-i18n";
|
import {
|
||||||
|
At,
|
||||||
|
Reply as ReplyIcon,
|
||||||
|
File,
|
||||||
|
XCircle,
|
||||||
|
} from "@styled-icons/boxicons-regular";
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import UserShort from "../../user/UserShort";
|
|
||||||
import IconButton from "../../../ui/IconButton";
|
import { Text } from "preact-i18n";
|
||||||
import Markdown from "../../../markdown/Markdown";
|
|
||||||
import { StateUpdater, useEffect } from "preact/hooks";
|
import { StateUpdater, useEffect } from "preact/hooks";
|
||||||
import { ReplyBase } from "../attachments/MessageReply";
|
|
||||||
import { Reply } from "../../../../redux/reducers/queue";
|
|
||||||
import { useUsers } from "../../../../context/revoltjs/hooks";
|
|
||||||
import { internalSubscribe } from "../../../../lib/eventEmitter";
|
import { internalSubscribe } from "../../../../lib/eventEmitter";
|
||||||
import { useRenderState } from "../../../../lib/renderer/Singleton";
|
import { useRenderState } from "../../../../lib/renderer/Singleton";
|
||||||
import { At, Reply as ReplyIcon, File, XCircle } from "@styled-icons/boxicons-regular";
|
|
||||||
|
import { Reply } from "../../../../redux/reducers/queue";
|
||||||
|
|
||||||
|
import { useUsers } from "../../../../context/revoltjs/hooks";
|
||||||
|
|
||||||
|
import IconButton from "../../../ui/IconButton";
|
||||||
|
|
||||||
|
import Markdown from "../../../markdown/Markdown";
|
||||||
|
import UserShort from "../../user/UserShort";
|
||||||
|
import { ReplyBase } from "../attachments/MessageReply";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
channel: string,
|
channel: string;
|
||||||
replies: Reply[],
|
replies: Reply[];
|
||||||
setReplies: StateUpdater<Reply[]>
|
setReplies: StateUpdater<Reply[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Base = styled.div`
|
const Base = styled.div`
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 0 22px;
|
padding: 0 22px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: var(--message-box);
|
background: var(--message-box);
|
||||||
|
|
||||||
div {
|
div {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toggle {
|
.toggle {
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
display: flex;
|
display: flex;
|
||||||
font-size: 0.7em;
|
font-size: 0.7em;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// ! FIXME: Move to global config
|
// ! FIXME: Move to global config
|
||||||
const MAX_REPLIES = 5;
|
const MAX_REPLIES = 5;
|
||||||
export default function ReplyBar({ channel, replies, setReplies }: Props) {
|
export default function ReplyBar({ channel, replies, setReplies }: Props) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return internalSubscribe("ReplyBar", "add", id => replies.length < MAX_REPLIES && !replies.find(x => x.id === id) && setReplies([ ...replies, { id, mention: false } ]));
|
return internalSubscribe(
|
||||||
}, [ replies ]);
|
"ReplyBar",
|
||||||
|
"add",
|
||||||
|
(id) =>
|
||||||
|
replies.length < MAX_REPLIES &&
|
||||||
|
!replies.find((x) => x.id === id) &&
|
||||||
|
setReplies([...replies, { id, mention: false }]),
|
||||||
|
);
|
||||||
|
}, [replies]);
|
||||||
|
|
||||||
const view = useRenderState(channel);
|
const view = useRenderState(channel);
|
||||||
if (view?.type !== 'RENDER') return null;
|
if (view?.type !== "RENDER") return null;
|
||||||
|
|
||||||
const ids = replies.map(x => x.id);
|
const ids = replies.map((x) => x.id);
|
||||||
const messages = view.messages.filter(x => ids.includes(x._id));
|
const messages = view.messages.filter((x) => ids.includes(x._id));
|
||||||
const users = useUsers(messages.map(x => x.author));
|
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>;
|
|
||||||
|
|
||||||
let user = users.find(x => message!.author === x?._id);
|
return (
|
||||||
if (!user) 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 (
|
let user = users.find((x) => message!.author === x?._id);
|
||||||
<Base key={reply.id}>
|
if (!user) return;
|
||||||
<ReplyBase preview>
|
|
||||||
<ReplyIcon size={22} />
|
return (
|
||||||
<UserShort user={user} size={16} />
|
<Base key={reply.id}>
|
||||||
{ message.attachments && message.attachments.length > 0 && <File size={16} /> }
|
<ReplyBase preview>
|
||||||
<Markdown disallowBigEmoji content={(message.content as string).replace(/\n/g, ' ')} />
|
<ReplyIcon size={22} />
|
||||||
</ReplyBase>
|
<UserShort user={user} size={16} />
|
||||||
<span class="actions">
|
{message.attachments &&
|
||||||
<IconButton onClick={() => setReplies(replies.map((_, i) => i === index ? { ..._, mention: !_.mention } : _))}>
|
message.attachments.length > 0 && (
|
||||||
<span class="toggle">
|
<File size={16} />
|
||||||
<At size={16} /> { reply.mention ? 'ON' : 'OFF' }
|
)}
|
||||||
</span>
|
<Markdown
|
||||||
</IconButton>
|
disallowBigEmoji
|
||||||
<IconButton onClick={() => setReplies(replies.filter((_, i) => i !== index))}>
|
content={(message.content as string).replace(
|
||||||
<XCircle size={16} />
|
/\n/g,
|
||||||
</IconButton>
|
" ",
|
||||||
</span>
|
)}
|
||||||
</Base>
|
/>
|
||||||
)
|
</ReplyBase>
|
||||||
}) }
|
<span class="actions">
|
||||||
</div>
|
<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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,111 +1,121 @@
|
||||||
import { User } from 'revolt.js';
|
import { User } from "revolt.js";
|
||||||
|
import styled from "styled-components";
|
||||||
|
|
||||||
import { Text } from "preact-i18n";
|
import { Text } from "preact-i18n";
|
||||||
import styled from 'styled-components';
|
import { useContext } from "preact/hooks";
|
||||||
import { useContext } from 'preact/hooks';
|
|
||||||
import { connectState } from '../../../../redux/connector';
|
import { connectState } from "../../../../redux/connector";
|
||||||
import { useUsers } from '../../../../context/revoltjs/hooks';
|
import { TypingUser } from "../../../../redux/reducers/typing";
|
||||||
import { TypingUser } from '../../../../redux/reducers/typing';
|
|
||||||
import { AppContext } from '../../../../context/revoltjs/RevoltClient';
|
import { AppContext } from "../../../../context/revoltjs/RevoltClient";
|
||||||
|
import { useUsers } from "../../../../context/revoltjs/hooks";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
typing?: TypingUser[]
|
typing?: TypingUser[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const Base = styled.div`
|
const Base = styled.div`
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
> div {
|
> div {
|
||||||
height: 24px;
|
height: 24px;
|
||||||
margin-top: -24px;
|
margin-top: -24px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 0 10px;
|
padding: 0 10px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
width: calc(100% - 3px);
|
width: calc(100% - 3px);
|
||||||
color: var(--secondary-foreground);
|
color: var(--secondary-foreground);
|
||||||
background: var(--secondary-background);
|
background: var(--secondary-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatars {
|
.avatars {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
img {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
object-fit: cover;
|
|
||||||
border-radius: 50%;
|
|
||||||
|
|
||||||
&:not(:first-child) {
|
img {
|
||||||
margin-left: -4px;
|
width: 16px;
|
||||||
}
|
height: 16px;
|
||||||
}
|
object-fit: cover;
|
||||||
}
|
border-radius: 50%;
|
||||||
|
|
||||||
.usernames {
|
&:not(:first-child) {
|
||||||
min-width: 0;
|
margin-left: -4px;
|
||||||
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) {
|
export function TypingIndicator({ typing }: Props) {
|
||||||
if (typing && typing.length > 0) {
|
if (typing && typing.length > 0) {
|
||||||
const client = useContext(AppContext);
|
const client = useContext(AppContext);
|
||||||
const users = useUsers(typing.map(x => x.id))
|
const users = useUsers(typing.map((x) => x.id)).filter(
|
||||||
.filter(x => typeof x !== 'undefined') as User[];
|
(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;
|
let text;
|
||||||
if (users.length >= 5) {
|
if (users.length >= 5) {
|
||||||
text = <Text id="app.main.channel.typing.several" />;
|
text = <Text id="app.main.channel.typing.several" />;
|
||||||
} else if (users.length > 1) {
|
} else if (users.length > 1) {
|
||||||
const usersCopy = [...users];
|
const usersCopy = [...users];
|
||||||
text = (
|
text = (
|
||||||
<Text
|
<Text
|
||||||
id="app.main.channel.typing.multiple"
|
id="app.main.channel.typing.multiple"
|
||||||
fields={{
|
fields={{
|
||||||
user: usersCopy.pop()?.username,
|
user: usersCopy.pop()?.username,
|
||||||
userlist: usersCopy.map(x => x.username).join(", ")
|
userlist: usersCopy.map((x) => x.username).join(", "),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
text = (
|
text = (
|
||||||
<Text
|
<Text
|
||||||
id="app.main.channel.typing.single"
|
id="app.main.channel.typing.single"
|
||||||
fields={{ user: users[0].username }}
|
fields={{ user: users[0].username }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Base>
|
<Base>
|
||||||
<div>
|
<div>
|
||||||
<div className="avatars">
|
<div className="avatars">
|
||||||
{users.map(user => (
|
{users.map((user) => (
|
||||||
<img
|
<img
|
||||||
src={client.users.getAvatarURL(user._id, { max_side: 256 }, true)}
|
src={client.users.getAvatarURL(
|
||||||
/>
|
user._id,
|
||||||
))}
|
{ max_side: 256 },
|
||||||
</div>
|
true,
|
||||||
<div className="usernames">{text}</div>
|
)}
|
||||||
</div>
|
/>
|
||||||
</Base>
|
))}
|
||||||
);
|
</div>
|
||||||
}
|
<div className="usernames">{text}</div>
|
||||||
|
</div>
|
||||||
|
</Base>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connectState<{ id: string }>(TypingIndicator, (state, props) => {
|
export default connectState<{ id: string }>(TypingIndicator, (state, props) => {
|
||||||
return {
|
return {
|
||||||
typing: state.typing && state.typing[props.id]
|
typing: state.typing && state.typing[props.id],
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,13 +1,16 @@
|
||||||
import classNames from 'classnames';
|
|
||||||
import EmbedMedia from './EmbedMedia';
|
|
||||||
import styles from "./Embed.module.scss";
|
|
||||||
import { useContext } from 'preact/hooks';
|
|
||||||
import { Embed as EmbedRJS } from "revolt.js/dist/api/objects";
|
import { Embed as EmbedRJS } from "revolt.js/dist/api/objects";
|
||||||
import { useIntermediate } from '../../../../context/intermediate/Intermediate';
|
|
||||||
import { MessageAreaWidthContext } from '../../../../pages/channels/messaging/MessageArea';
|
import styles from "./Embed.module.scss";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { useContext } from "preact/hooks";
|
||||||
|
|
||||||
|
import { useIntermediate } from "../../../../context/intermediate/Intermediate";
|
||||||
|
|
||||||
|
import { MessageAreaWidthContext } from "../../../../pages/channels/messaging/MessageArea";
|
||||||
|
import EmbedMedia from "./EmbedMedia";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
embed: EmbedRJS;
|
embed: EmbedRJS;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MAX_EMBED_WIDTH = 480;
|
const MAX_EMBED_WIDTH = 480;
|
||||||
|
@ -16,113 +19,149 @@ const CONTAINER_PADDING = 24;
|
||||||
const MAX_PREVIEW_SIZE = 150;
|
const MAX_PREVIEW_SIZE = 150;
|
||||||
|
|
||||||
export default function Embed({ embed }: Props) {
|
export default function Embed({ embed }: Props) {
|
||||||
// ! FIXME: temp code
|
// ! FIXME: temp code
|
||||||
// ! add proxy function to client
|
// ! add proxy function to client
|
||||||
function proxyImage(url: string) {
|
function proxyImage(url: string) {
|
||||||
return 'https://jan.revolt.chat/proxy?url=' + encodeURIComponent(url);
|
return "https://jan.revolt.chat/proxy?url=" + encodeURIComponent(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { openScreen } = useIntermediate();
|
const { openScreen } = useIntermediate();
|
||||||
const maxWidth = Math.min(useContext(MessageAreaWidthContext) - CONTAINER_PADDING, MAX_EMBED_WIDTH);
|
const maxWidth = Math.min(
|
||||||
|
useContext(MessageAreaWidthContext) - CONTAINER_PADDING,
|
||||||
|
MAX_EMBED_WIDTH,
|
||||||
|
);
|
||||||
|
|
||||||
function calculateSize(w: number, h: number): { width: number, height: number } {
|
function calculateSize(
|
||||||
let limitingWidth = Math.min(
|
w: number,
|
||||||
maxWidth,
|
h: number,
|
||||||
w
|
): { width: number; height: number } {
|
||||||
);
|
let limitingWidth = Math.min(maxWidth, w);
|
||||||
|
|
||||||
let limitingHeight = Math.min(
|
let limitingHeight = Math.min(MAX_EMBED_HEIGHT, h);
|
||||||
MAX_EMBED_HEIGHT,
|
|
||||||
h
|
|
||||||
);
|
|
||||||
|
|
||||||
// Calculate smallest possible WxH.
|
// Calculate smallest possible WxH.
|
||||||
let width = Math.min(
|
let width = Math.min(limitingWidth, limitingHeight * (w / h));
|
||||||
limitingWidth,
|
|
||||||
limitingHeight * (w / h)
|
|
||||||
);
|
|
||||||
|
|
||||||
let height = Math.min(
|
let height = Math.min(limitingHeight, limitingWidth * (h / w));
|
||||||
limitingHeight,
|
|
||||||
limitingWidth * (h / w)
|
|
||||||
);
|
|
||||||
|
|
||||||
return { width, height };
|
return { width, height };
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (embed.type) {
|
switch (embed.type) {
|
||||||
case 'Website': {
|
case "Website": {
|
||||||
// Determine special embed size.
|
// Determine special embed size.
|
||||||
let mw, mh;
|
let mw, mh;
|
||||||
let largeMedia = (embed.special && embed.special.type !== 'None') || embed.image?.size === 'Large';
|
let largeMedia =
|
||||||
switch (embed.special?.type) {
|
(embed.special && embed.special.type !== "None") ||
|
||||||
case 'YouTube':
|
embed.image?.size === "Large";
|
||||||
case 'Bandcamp': {
|
switch (embed.special?.type) {
|
||||||
mw = embed.video?.width ?? 1280;
|
case "YouTube":
|
||||||
mh = embed.video?.height ?? 720;
|
case "Bandcamp": {
|
||||||
break;
|
mw = embed.video?.width ?? 1280;
|
||||||
}
|
mh = embed.video?.height ?? 720;
|
||||||
case 'Twitch': {
|
break;
|
||||||
mw = 1280;
|
}
|
||||||
mh = 720;
|
case "Twitch": {
|
||||||
break;
|
mw = 1280;
|
||||||
}
|
mh = 720;
|
||||||
default: {
|
break;
|
||||||
if (embed.image?.size === 'Preview') {
|
}
|
||||||
mw = MAX_EMBED_WIDTH;
|
default: {
|
||||||
mh = Math.min(embed.image.height ?? 0, MAX_PREVIEW_SIZE);
|
if (embed.image?.size === "Preview") {
|
||||||
} else {
|
mw = MAX_EMBED_WIDTH;
|
||||||
mw = embed.image?.width ?? MAX_EMBED_WIDTH;
|
mh = Math.min(
|
||||||
mh = embed.image?.height ?? 0;
|
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);
|
let { width, height } = calculateSize(mw, mh);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames(styles.embed, styles.website)}
|
className={classNames(styles.embed, styles.website)}
|
||||||
style={{
|
style={{
|
||||||
borderInlineStartColor: embed.color ?? 'var(--tertiary-background)',
|
borderInlineStartColor:
|
||||||
width: width + CONTAINER_PADDING
|
embed.color ?? "var(--tertiary-background)",
|
||||||
}}>
|
width: width + CONTAINER_PADDING,
|
||||||
<div>
|
}}>
|
||||||
{ embed.site_name && <div className={styles.siteinfo}>
|
<div>
|
||||||
{ embed.icon_url && <img className={styles.favicon} src={proxyImage(embed.icon_url)} draggable={false} onError={e => e.currentTarget.style.display = 'none'} /> }
|
{embed.site_name && (
|
||||||
<div className={styles.site}>{ embed.site_name } </div>
|
<div className={styles.siteinfo}>
|
||||||
</div> }
|
{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>*/}
|
{/*<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.title && (
|
||||||
{ embed.description && <div className={styles.description}>{ embed.description }</div> }
|
<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} /> }
|
{largeMedia && (
|
||||||
</div>
|
<EmbedMedia embed={embed} height={height} />
|
||||||
{
|
)}
|
||||||
!largeMedia && <div>
|
</div>
|
||||||
<EmbedMedia embed={embed} width={height * ((embed.image?.width ?? 0) / (embed.image?.height ?? 0))} height={height} />
|
{!largeMedia && (
|
||||||
</div>
|
<div>
|
||||||
}
|
<EmbedMedia
|
||||||
</div>
|
embed={embed}
|
||||||
)
|
width={
|
||||||
}
|
height *
|
||||||
case 'Image': {
|
((embed.image?.width ?? 0) /
|
||||||
return (
|
(embed.image?.height ?? 0))
|
||||||
<img className={classNames(styles.embed, styles.image)}
|
}
|
||||||
style={calculateSize(embed.width, embed.height)}
|
height={height}
|
||||||
src={proxyImage(embed.url)}
|
/>
|
||||||
type="text/html"
|
</div>
|
||||||
frameBorder="0"
|
)}
|
||||||
onClick={() =>
|
</div>
|
||||||
openScreen({ id: "image_viewer", embed })
|
);
|
||||||
}
|
}
|
||||||
onMouseDown={ev =>
|
case "Image": {
|
||||||
ev.button === 1 &&
|
return (
|
||||||
window.open(embed.url, "_blank")
|
<img
|
||||||
}
|
className={classNames(styles.embed, styles.image)}
|
||||||
/>
|
style={calculateSize(embed.width, embed.height)}
|
||||||
)
|
src={proxyImage(embed.url)}
|
||||||
}
|
type="text/html"
|
||||||
default: return null;
|
frameBorder="0"
|
||||||
}
|
onClick={() => openScreen({ id: "image_viewer", embed })}
|
||||||
|
onMouseDown={(ev) =>
|
||||||
|
ev.button === 1 && window.open(embed.url, "_blank")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,78 +1,100 @@
|
||||||
import styles from './Embed.module.scss';
|
|
||||||
import { Embed } from "revolt.js/dist/api/objects";
|
import { Embed } from "revolt.js/dist/api/objects";
|
||||||
import { useIntermediate } from '../../../../context/intermediate/Intermediate';
|
|
||||||
|
import styles from "./Embed.module.scss";
|
||||||
|
|
||||||
|
import { useIntermediate } from "../../../../context/intermediate/Intermediate";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
embed: Embed;
|
embed: Embed;
|
||||||
width?: number;
|
width?: number;
|
||||||
height: number;
|
height: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EmbedMedia({ embed, width, height }: Props) {
|
export default function EmbedMedia({ embed, width, height }: Props) {
|
||||||
// ! FIXME: temp code
|
// ! FIXME: temp code
|
||||||
// ! add proxy function to client
|
// ! add proxy function to client
|
||||||
function proxyImage(url: string) {
|
function proxyImage(url: string) {
|
||||||
return 'https://jan.revolt.chat/proxy?url=' + encodeURIComponent(url);
|
return "https://jan.revolt.chat/proxy?url=" + encodeURIComponent(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (embed.type !== 'Website') return null;
|
if (embed.type !== "Website") return null;
|
||||||
const { openScreen } = useIntermediate();
|
const { openScreen } = useIntermediate();
|
||||||
|
|
||||||
switch (embed.special?.type) {
|
switch (embed.special?.type) {
|
||||||
case 'YouTube': return (
|
case "YouTube":
|
||||||
<iframe
|
return (
|
||||||
src={`https://www.youtube-nocookie.com/embed/${embed.special.id}?modestbranding=1`}
|
<iframe
|
||||||
allowFullScreen
|
src={`https://www.youtube-nocookie.com/embed/${embed.special.id}?modestbranding=1`}
|
||||||
style={{ height }} />
|
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`}
|
case "Twitch":
|
||||||
frameBorder="0"
|
return (
|
||||||
allowFullScreen
|
<iframe
|
||||||
scrolling="no"
|
src={`https://player.twitch.tv/?${embed.special.content_type.toLowerCase()}=${
|
||||||
style={{ height, }} />
|
embed.special.id
|
||||||
)
|
}&parent=${window.location.hostname}&autoplay=false`}
|
||||||
case 'Spotify': return (
|
frameBorder="0"
|
||||||
<iframe
|
allowFullScreen
|
||||||
src={`https://open.spotify.com/embed/${embed.special.content_type}/${embed.special.id}`}
|
scrolling="no"
|
||||||
frameBorder="0"
|
style={{ height }}
|
||||||
allowFullScreen
|
/>
|
||||||
allowTransparency
|
);
|
||||||
style={{ height }} />
|
case "Spotify":
|
||||||
)
|
return (
|
||||||
case 'Soundcloud': return (
|
<iframe
|
||||||
<iframe
|
src={`https://open.spotify.com/embed/${embed.special.content_type}/${embed.special.id}`}
|
||||||
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"
|
||||||
frameBorder="0"
|
allowFullScreen
|
||||||
scrolling="no"
|
allowTransparency
|
||||||
style={{ height }} />
|
style={{ height }}
|
||||||
)
|
/>
|
||||||
case 'Bandcamp': {
|
);
|
||||||
return <iframe
|
case "Soundcloud":
|
||||||
src={`https://bandcamp.com/EmbeddedPlayer/${embed.special.content_type.toLowerCase()}=${embed.special.id}/size=large/bgcol=181a1b/linkcol=056cc4/tracklist=false/transparent=true/`}
|
return (
|
||||||
seamless
|
<iframe
|
||||||
style={{ height }} />;
|
src={`https://w.soundcloud.com/player/?url=${encodeURIComponent(
|
||||||
}
|
embed.url!,
|
||||||
default: {
|
)}&color=%23FF7F50&auto_play=false&hide_related=false&show_comments=true&show_user=true&show_reposts=false&show_teaser=true&visual=true`}
|
||||||
if (embed.image) {
|
frameBorder="0"
|
||||||
let url = embed.image.url;
|
scrolling="no"
|
||||||
return (
|
style={{ height }}
|
||||||
<img
|
/>
|
||||||
className={styles.image}
|
);
|
||||||
src={proxyImage(url)}
|
case "Bandcamp": {
|
||||||
style={{ width, height }}
|
return (
|
||||||
onClick={() =>
|
<iframe
|
||||||
openScreen({ id: "image_viewer", embed: embed.image })
|
src={`https://bandcamp.com/EmbeddedPlayer/${embed.special.content_type.toLowerCase()}=${
|
||||||
}
|
embed.special.id
|
||||||
onMouseDown={ev =>
|
}/size=large/bgcol=181a1b/linkcol=056cc4/tracklist=false/transparent=true/`}
|
||||||
ev.button === 1 &&
|
seamless
|
||||||
window.open(url, "_blank")
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,26 +1,30 @@
|
||||||
import styles from './Embed.module.scss';
|
import { LinkExternal } from "@styled-icons/boxicons-regular";
|
||||||
import IconButton from '../../../ui/IconButton';
|
|
||||||
import { LinkExternal } from '@styled-icons/boxicons-regular';
|
|
||||||
import { EmbedImage } from "revolt.js/dist/api/objects";
|
import { EmbedImage } from "revolt.js/dist/api/objects";
|
||||||
|
|
||||||
|
import styles from "./Embed.module.scss";
|
||||||
|
|
||||||
|
import IconButton from "../../../ui/IconButton";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
embed: EmbedImage;
|
embed: EmbedImage;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function EmbedMediaActions({ embed }: Props) {
|
export default function EmbedMediaActions({ embed }: Props) {
|
||||||
const filename = embed.url.split('/').pop();
|
const filename = embed.url.split("/").pop();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.actions}>
|
<div className={styles.actions}>
|
||||||
<div className={styles.info}>
|
<div className={styles.info}>
|
||||||
<span className={styles.filename}>{filename}</span>
|
<span className={styles.filename}>{filename}</span>
|
||||||
<span className={styles.filesize}>{embed.width + 'x' + embed.height}</span>
|
<span className={styles.filesize}>
|
||||||
</div>
|
{embed.width + "x" + embed.height}
|
||||||
<a href={embed.url} target="_blank">
|
</span>
|
||||||
<IconButton>
|
</div>
|
||||||
<LinkExternal size={24} />
|
<a href={embed.url} target="_blank">
|
||||||
</IconButton>
|
<IconButton>
|
||||||
</a>
|
<LinkExternal size={24} />
|
||||||
</div>
|
</IconButton>
|
||||||
)
|
</a>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +1,16 @@
|
||||||
import { User } from "revolt.js";
|
import { User } from "revolt.js";
|
||||||
import UserIcon from "./UserIcon";
|
|
||||||
import Checkbox, { CheckboxProps } from "../../ui/Checkbox";
|
import Checkbox, { CheckboxProps } from "../../ui/Checkbox";
|
||||||
|
|
||||||
|
import UserIcon from "./UserIcon";
|
||||||
|
|
||||||
type UserProps = Omit<CheckboxProps, "children"> & { user: User };
|
type UserProps = Omit<CheckboxProps, "children"> & { user: User };
|
||||||
|
|
||||||
export default function UserCheckbox({ user, ...props }: UserProps) {
|
export default function UserCheckbox({ user, ...props }: UserProps) {
|
||||||
return (
|
return (
|
||||||
<Checkbox {...props}>
|
<Checkbox {...props}>
|
||||||
<UserIcon target={user} size={32} />
|
<UserIcon target={user} size={32} />
|
||||||
{user.username}
|
{user.username}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,75 +1,84 @@
|
||||||
import Tooltip from "../Tooltip";
|
|
||||||
import { User } from "revolt.js";
|
|
||||||
import UserIcon from "./UserIcon";
|
|
||||||
import { Text } from "preact-i18n";
|
|
||||||
import Header from "../../ui/Header";
|
|
||||||
import UserStatus from './UserStatus';
|
|
||||||
import styled from "styled-components";
|
|
||||||
import { Localizer } from 'preact-i18n';
|
|
||||||
import { Link } from "react-router-dom";
|
|
||||||
import IconButton from "../../ui/IconButton";
|
|
||||||
import { Cog } from "@styled-icons/boxicons-solid";
|
import { Cog } from "@styled-icons/boxicons-solid";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { User } from "revolt.js";
|
||||||
|
import styled from "styled-components";
|
||||||
|
|
||||||
import { openContextMenu } from "preact-context-menu";
|
import { openContextMenu } from "preact-context-menu";
|
||||||
|
import { Text } from "preact-i18n";
|
||||||
|
import { Localizer } from "preact-i18n";
|
||||||
|
|
||||||
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
|
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
|
||||||
|
|
||||||
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
||||||
|
|
||||||
|
import Header from "../../ui/Header";
|
||||||
|
import IconButton from "../../ui/IconButton";
|
||||||
|
|
||||||
|
import Tooltip from "../Tooltip";
|
||||||
|
import UserIcon from "./UserIcon";
|
||||||
|
import UserStatus from "./UserStatus";
|
||||||
|
|
||||||
const HeaderBase = styled.div`
|
const HeaderBase = styled.div`
|
||||||
gap: 0;
|
gap: 0;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
* {
|
|
||||||
min-width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
.username {
|
* {
|
||||||
cursor: pointer;
|
min-width: 0;
|
||||||
font-size: 16px;
|
overflow: hidden;
|
||||||
font-weight: 600;
|
white-space: nowrap;
|
||||||
}
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
.status {
|
.username {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 12px;
|
font-size: 16px;
|
||||||
margin-top: -2px;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: -2px;
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UserHeader({ user }: Props) {
|
export default function UserHeader({ user }: Props) {
|
||||||
const { writeClipboard } = useIntermediate();
|
const { writeClipboard } = useIntermediate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Header borders placement="secondary">
|
<Header borders placement="secondary">
|
||||||
<HeaderBase>
|
<HeaderBase>
|
||||||
<Localizer>
|
<Localizer>
|
||||||
<Tooltip content={<Text id="app.special.copy_username" />}>
|
<Tooltip content={<Text id="app.special.copy_username" />}>
|
||||||
<span className="username"
|
<span
|
||||||
onClick={() => writeClipboard(user.username)}>
|
className="username"
|
||||||
@{user.username}
|
onClick={() => writeClipboard(user.username)}>
|
||||||
</span>
|
@{user.username}
|
||||||
</Tooltip>
|
</span>
|
||||||
</Localizer>
|
</Tooltip>
|
||||||
<span className="status"
|
</Localizer>
|
||||||
onClick={() => openContextMenu("Status")}>
|
<span
|
||||||
<UserStatus user={user} />
|
className="status"
|
||||||
</span>
|
onClick={() => openContextMenu("Status")}>
|
||||||
</HeaderBase>
|
<UserStatus user={user} />
|
||||||
{ !isTouchscreenDevice && <div className="actions">
|
</span>
|
||||||
<Link to="/settings">
|
</HeaderBase>
|
||||||
<IconButton>
|
{!isTouchscreenDevice && (
|
||||||
<Cog size={24} />
|
<div className="actions">
|
||||||
</IconButton>
|
<Link to="/settings">
|
||||||
</Link>
|
<IconButton>
|
||||||
</div> }
|
<Cog size={24} />
|
||||||
</Header>
|
</IconButton>
|
||||||
)
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Header>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,93 +1,104 @@
|
||||||
import { User } from "revolt.js";
|
|
||||||
import { useContext } from "preact/hooks";
|
|
||||||
import { MicrophoneOff } from "@styled-icons/boxicons-regular";
|
import { MicrophoneOff } from "@styled-icons/boxicons-regular";
|
||||||
import styled, { css } from "styled-components";
|
import { User } from "revolt.js";
|
||||||
import { Users } from "revolt.js/dist/api/objects";
|
import { Users } from "revolt.js/dist/api/objects";
|
||||||
|
import styled, { css } from "styled-components";
|
||||||
|
|
||||||
|
import { useContext } from "preact/hooks";
|
||||||
|
|
||||||
import { ThemeContext } from "../../../context/Theme";
|
import { ThemeContext } from "../../../context/Theme";
|
||||||
import IconBase, { IconBaseProps } from "../IconBase";
|
|
||||||
import { AppContext } from "../../../context/revoltjs/RevoltClient";
|
import { AppContext } from "../../../context/revoltjs/RevoltClient";
|
||||||
|
|
||||||
|
import IconBase, { IconBaseProps } from "../IconBase";
|
||||||
|
import fallback from "../assets/user.png";
|
||||||
|
|
||||||
type VoiceStatus = "muted";
|
type VoiceStatus = "muted";
|
||||||
interface Props extends IconBaseProps<User> {
|
interface Props extends IconBaseProps<User> {
|
||||||
mask?: string;
|
mask?: string;
|
||||||
status?: boolean;
|
status?: boolean;
|
||||||
voice?: VoiceStatus;
|
voice?: VoiceStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useStatusColour(user?: User) {
|
export function useStatusColour(user?: User) {
|
||||||
const theme = useContext(ThemeContext);
|
const theme = useContext(ThemeContext);
|
||||||
|
|
||||||
return (
|
return user?.online && user?.status?.presence !== Users.Presence.Invisible
|
||||||
user?.online &&
|
? user?.status?.presence === Users.Presence.Idle
|
||||||
user?.status?.presence !== Users.Presence.Invisible
|
? theme["status-away"]
|
||||||
? user?.status?.presence === Users.Presence.Idle
|
: user?.status?.presence === Users.Presence.Busy
|
||||||
? theme["status-away"]
|
? theme["status-busy"]
|
||||||
: user?.status?.presence ===
|
: theme["status-online"]
|
||||||
Users.Presence.Busy
|
: theme["status-invisible"];
|
||||||
? theme["status-busy"]
|
|
||||||
: theme["status-online"]
|
|
||||||
: theme["status-invisible"]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const VoiceIndicator = styled.div<{ status: VoiceStatus }>`
|
const VoiceIndicator = styled.div<{ status: VoiceStatus }>`
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
stroke: white;
|
stroke: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
${ props => props.status === 'muted' && css`
|
${(props) =>
|
||||||
background: var(--error);
|
props.status === "muted" &&
|
||||||
` }
|
css`
|
||||||
|
background: var(--error);
|
||||||
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
import fallback from '../assets/user.png';
|
export default function UserIcon(
|
||||||
|
props: Props & Omit<JSX.SVGAttributes<SVGSVGElement>, keyof Props>,
|
||||||
|
) {
|
||||||
|
const client = useContext(AppContext);
|
||||||
|
|
||||||
export default function UserIcon(props: Props & Omit<JSX.SVGAttributes<SVGSVGElement>, keyof Props>) {
|
const {
|
||||||
const client = useContext(AppContext);
|
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;
|
return (
|
||||||
const iconURL = client.generateFileURL(target?.avatar ?? attachment, { max_side: 256 }, animate)
|
<IconBase
|
||||||
?? (target ? client.users.getDefaultAvatarURL(target._id) : fallback);
|
{...svgProps}
|
||||||
|
width={size}
|
||||||
return (
|
height={size}
|
||||||
<IconBase {...svgProps}
|
aria-hidden="true"
|
||||||
width={size}
|
viewBox="0 0 32 32">
|
||||||
height={size}
|
<foreignObject
|
||||||
aria-hidden="true"
|
x="0"
|
||||||
viewBox="0 0 32 32">
|
y="0"
|
||||||
<foreignObject x="0" y="0" width="32" height="32" mask={mask ?? (status ? "url(#user)" : undefined)}>
|
width="32"
|
||||||
{
|
height="32"
|
||||||
<img src={iconURL}
|
mask={mask ?? (status ? "url(#user)" : undefined)}>
|
||||||
draggable={false} />
|
{<img src={iconURL} draggable={false} />}
|
||||||
}
|
</foreignObject>
|
||||||
</foreignObject>
|
{props.status && (
|
||||||
{props.status && (
|
<circle cx="27" cy="27" r="5" fill={useStatusColour(target)} />
|
||||||
<circle
|
)}
|
||||||
cx="27"
|
{props.voice && (
|
||||||
cy="27"
|
<foreignObject x="22" y="22" width="10" height="10">
|
||||||
r="5"
|
<VoiceIndicator status={props.voice}>
|
||||||
fill={useStatusColour(target)}
|
{props.voice === "muted" && <MicrophoneOff size={6} />}
|
||||||
/>
|
</VoiceIndicator>
|
||||||
)}
|
</foreignObject>
|
||||||
{props.voice && (
|
)}
|
||||||
<foreignObject
|
</IconBase>
|
||||||
x="22"
|
);
|
||||||
y="22"
|
|
||||||
width="10"
|
|
||||||
height="10">
|
|
||||||
<VoiceIndicator status={props.voice}>
|
|
||||||
{props.voice === "muted" && <MicrophoneOff size={6} />}
|
|
||||||
</VoiceIndicator>
|
|
||||||
</foreignObject>
|
|
||||||
)}
|
|
||||||
</IconBase>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +1,31 @@
|
||||||
import { User } from "revolt.js";
|
import { User } from "revolt.js";
|
||||||
import UserIcon from "./UserIcon";
|
|
||||||
import { Text } from "preact-i18n";
|
import { Text } from "preact-i18n";
|
||||||
|
|
||||||
export function Username({ user, ...otherProps }: { user?: User } & JSX.HTMLAttributes<HTMLElement>) {
|
import UserIcon from "./UserIcon";
|
||||||
return <span {...otherProps}>{ user?.username ?? <Text id="app.main.channel.unknown_user" /> }</span>;
|
|
||||||
|
export function Username({
|
||||||
|
user,
|
||||||
|
...otherProps
|
||||||
|
}: { user?: User } & JSX.HTMLAttributes<HTMLElement>) {
|
||||||
|
return (
|
||||||
|
<span {...otherProps}>
|
||||||
|
{user?.username ?? <Text id="app.main.channel.unknown_user" />}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UserShort({ user, size }: { user?: User, size?: number }) {
|
export default function UserShort({
|
||||||
return <>
|
user,
|
||||||
<UserIcon size={size ?? 24} target={user} />
|
size,
|
||||||
<Username user={user} />
|
}: {
|
||||||
</>;
|
user?: User;
|
||||||
}
|
size?: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<UserIcon size={size ?? 24} target={user} />
|
||||||
|
<Username user={user} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -1,31 +1,32 @@
|
||||||
import { User } from "revolt.js";
|
import { User } from "revolt.js";
|
||||||
import { Text } from "preact-i18n";
|
|
||||||
import { Users } from "revolt.js/dist/api/objects";
|
import { Users } from "revolt.js/dist/api/objects";
|
||||||
|
|
||||||
|
import { Text } from "preact-i18n";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user: User;
|
user: User;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UserStatus({ user }: Props) {
|
export default function UserStatus({ user }: Props) {
|
||||||
if (user.online) {
|
if (user.online) {
|
||||||
if (user.status?.text) {
|
if (user.status?.text) {
|
||||||
return <>{user.status?.text}</>;
|
return <>{user.status?.text}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.status?.presence === Users.Presence.Busy) {
|
if (user.status?.presence === Users.Presence.Busy) {
|
||||||
return <Text id="app.status.busy" />;
|
return <Text id="app.status.busy" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.status?.presence === Users.Presence.Idle) {
|
if (user.status?.presence === Users.Presence.Idle) {
|
||||||
return <Text id="app.status.idle" />;
|
return <Text id="app.status.idle" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.status?.presence === Users.Presence.Invisible) {
|
if (user.status?.presence === Users.Presence.Invisible) {
|
||||||
return <Text id="app.status.offline" />;
|
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" />;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,17 +1,17 @@
|
||||||
import { Suspense, lazy } from "preact/compat";
|
import { Suspense, lazy } from "preact/compat";
|
||||||
|
|
||||||
const Renderer = lazy(() => import('./Renderer'));
|
const Renderer = lazy(() => import("./Renderer"));
|
||||||
|
|
||||||
export interface MarkdownProps {
|
export interface MarkdownProps {
|
||||||
content?: string;
|
content?: string;
|
||||||
disallowBigEmoji?: boolean;
|
disallowBigEmoji?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Markdown(props: MarkdownProps) {
|
export default function Markdown(props: MarkdownProps) {
|
||||||
return (
|
return (
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
<Suspense fallback={props.content}>
|
<Suspense fallback={props.content}>
|
||||||
<Renderer {...props} />
|
<Renderer {...props} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,187 +1,192 @@
|
||||||
import MarkdownIt from "markdown-it";
|
|
||||||
import { RE_MENTIONS } from "revolt.js";
|
|
||||||
import { useContext } from "preact/hooks";
|
|
||||||
import { MarkdownProps } from "./Markdown";
|
|
||||||
import styles from "./Markdown.module.scss";
|
|
||||||
import { generateEmoji } from "../common/Emoji";
|
|
||||||
import { internalEmit } from "../../lib/eventEmitter";
|
|
||||||
import { emojiDictionary } from "../../assets/emojis";
|
|
||||||
import { AppContext } from "../../context/revoltjs/RevoltClient";
|
|
||||||
|
|
||||||
import Prism from "prismjs";
|
|
||||||
import "katex/dist/katex.min.css";
|
|
||||||
import "prismjs/themes/prism-tomorrow.css";
|
|
||||||
|
|
||||||
import MarkdownKatex from "@traptitech/markdown-it-katex";
|
import MarkdownKatex from "@traptitech/markdown-it-katex";
|
||||||
import MarkdownSpoilers from "@traptitech/markdown-it-spoiler";
|
import MarkdownSpoilers from "@traptitech/markdown-it-spoiler";
|
||||||
|
import "katex/dist/katex.min.css";
|
||||||
|
import MarkdownIt from "markdown-it";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import MarkdownEmoji from "markdown-it-emoji/dist/markdown-it-emoji-bare";
|
import MarkdownEmoji from "markdown-it-emoji/dist/markdown-it-emoji-bare";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import MarkdownSup from "markdown-it-sup";
|
|
||||||
// @ts-ignore
|
|
||||||
import MarkdownSub from "markdown-it-sub";
|
import MarkdownSub from "markdown-it-sub";
|
||||||
|
// @ts-ignore
|
||||||
|
import MarkdownSup from "markdown-it-sup";
|
||||||
|
import Prism from "prismjs";
|
||||||
|
import "prismjs/themes/prism-tomorrow.css";
|
||||||
|
import { RE_MENTIONS } from "revolt.js";
|
||||||
|
|
||||||
|
import styles from "./Markdown.module.scss";
|
||||||
|
import { useContext } from "preact/hooks";
|
||||||
|
|
||||||
|
import { internalEmit } from "../../lib/eventEmitter";
|
||||||
|
|
||||||
|
import { AppContext } from "../../context/revoltjs/RevoltClient";
|
||||||
|
|
||||||
|
import { generateEmoji } from "../common/Emoji";
|
||||||
|
|
||||||
|
import { emojiDictionary } from "../../assets/emojis";
|
||||||
|
import { MarkdownProps } from "./Markdown";
|
||||||
|
|
||||||
// TODO: global.d.ts file for defining globals
|
// TODO: global.d.ts file for defining globals
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
copycode: (element: HTMLDivElement) => void
|
copycode: (element: HTMLDivElement) => void;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Handler for code block copy.
|
// Handler for code block copy.
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.copycode = function(element: HTMLDivElement) {
|
window.copycode = function (element: HTMLDivElement) {
|
||||||
try {
|
try {
|
||||||
let code = element.parentElement?.parentElement?.children[1];
|
let code = element.parentElement?.parentElement?.children[1];
|
||||||
if (code) {
|
if (code) {
|
||||||
navigator.clipboard.writeText(code.textContent?.trim() ?? '');
|
navigator.clipboard.writeText(code.textContent?.trim() ?? "");
|
||||||
}
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const md: MarkdownIt = MarkdownIt({
|
export const md: MarkdownIt = MarkdownIt({
|
||||||
breaks: true,
|
breaks: true,
|
||||||
linkify: true,
|
linkify: true,
|
||||||
highlight: (str, lang) => {
|
highlight: (str, lang) => {
|
||||||
let v = Prism.languages[lang];
|
let v = Prism.languages[lang];
|
||||||
if (v) {
|
if (v) {
|
||||||
let out = Prism.highlight(str, v, lang);
|
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"><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")
|
.disable("image")
|
||||||
.use(MarkdownEmoji, { defs: emojiDictionary })
|
.use(MarkdownEmoji, { defs: emojiDictionary })
|
||||||
.use(MarkdownSpoilers)
|
.use(MarkdownSpoilers)
|
||||||
.use(MarkdownSup)
|
.use(MarkdownSup)
|
||||||
.use(MarkdownSub)
|
.use(MarkdownSub)
|
||||||
.use(MarkdownKatex, {
|
.use(MarkdownKatex, {
|
||||||
throwOnError: false,
|
throwOnError: false,
|
||||||
maxExpand: 0
|
maxExpand: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// ? Force links to open _blank.
|
// ? Force links to open _blank.
|
||||||
// From: https://github.com/markdown-it/markdown-it/blob/master/docs/architecture.md#renderer
|
// From: https://github.com/markdown-it/markdown-it/blob/master/docs/architecture.md#renderer
|
||||||
const defaultRender =
|
const defaultRender =
|
||||||
md.renderer.rules.link_open ||
|
md.renderer.rules.link_open ||
|
||||||
function(tokens, idx, options, _env, self) {
|
function (tokens, idx, options, _env, self) {
|
||||||
return self.renderToken(tokens, idx, options);
|
return self.renderToken(tokens, idx, options);
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO: global.d.ts file for defining globals
|
// TODO: global.d.ts file for defining globals
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
internalHandleURL: (element: HTMLAnchorElement) => void
|
internalHandleURL: (element: HTMLAnchorElement) => void;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handler for internal links, pushes events to React using magic.
|
// Handler for internal links, pushes events to React using magic.
|
||||||
if (typeof window !== "undefined") {
|
if (typeof window !== "undefined") {
|
||||||
window.internalHandleURL = function(element: HTMLAnchorElement) {
|
window.internalHandleURL = function (element: HTMLAnchorElement) {
|
||||||
const url = new URL(element.href, location.href);
|
const url = new URL(element.href, location.href);
|
||||||
const pathname = url.pathname;
|
const pathname = url.pathname;
|
||||||
|
|
||||||
if (pathname.startsWith("/@")) {
|
if (pathname.startsWith("/@")) {
|
||||||
internalEmit("Intermediate", "openProfile", pathname.substr(2));
|
internalEmit("Intermediate", "openProfile", pathname.substr(2));
|
||||||
} else {
|
} else {
|
||||||
internalEmit("Intermediate", "navigate", pathname);
|
internalEmit("Intermediate", "navigate", pathname);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
md.renderer.rules.link_open = function(tokens, idx, options, env, self) {
|
md.renderer.rules.link_open = function (tokens, idx, options, env, self) {
|
||||||
let internal;
|
let internal;
|
||||||
const hIndex = tokens[idx].attrIndex("href");
|
const hIndex = tokens[idx].attrIndex("href");
|
||||||
if (hIndex >= 0) {
|
if (hIndex >= 0) {
|
||||||
try {
|
try {
|
||||||
// For internal links, we should use our own handler to use react-router history.
|
// For internal links, we should use our own handler to use react-router history.
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
const href = tokens[idx].attrs[hIndex][1];
|
const href = tokens[idx].attrs[hIndex][1];
|
||||||
const url = new URL(href, location.href);
|
const url = new URL(href, location.href);
|
||||||
|
|
||||||
if (url.hostname === location.hostname) {
|
if (url.hostname === location.hostname) {
|
||||||
internal = true;
|
internal = true;
|
||||||
// I'm sorry.
|
// I'm sorry.
|
||||||
tokens[idx].attrPush([
|
tokens[idx].attrPush([
|
||||||
"onclick",
|
"onclick",
|
||||||
"internalHandleURL(this); return false"
|
"internalHandleURL(this); return false",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (url.pathname.startsWith("/@")) {
|
if (url.pathname.startsWith("/@")) {
|
||||||
tokens[idx].attrPush(["data-type", "mention"]);
|
tokens[idx].attrPush(["data-type", "mention"]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Ignore the error, treat as normal link.
|
// Ignore the error, treat as normal link.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!internal) {
|
if (!internal) {
|
||||||
// Add target=_blank for external links.
|
// Add target=_blank for external links.
|
||||||
const aIndex = tokens[idx].attrIndex("target");
|
const aIndex = tokens[idx].attrIndex("target");
|
||||||
|
|
||||||
if (aIndex < 0) {
|
if (aIndex < 0) {
|
||||||
tokens[idx].attrPush(["target", "_blank"]);
|
tokens[idx].attrPush(["target", "_blank"]);
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
tokens[idx].attrs[aIndex][1] = "_blank";
|
tokens[idx].attrs[aIndex][1] = "_blank";
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return defaultRender(tokens, idx, options, env, self);
|
return defaultRender(tokens, idx, options, env, self);
|
||||||
};
|
};
|
||||||
|
|
||||||
md.renderer.rules.emoji = function(token, idx) {
|
md.renderer.rules.emoji = function (token, idx) {
|
||||||
return generateEmoji(token[idx].content);
|
return generateEmoji(token[idx].content);
|
||||||
};
|
};
|
||||||
|
|
||||||
const RE_TWEMOJI = /:(\w+):/g;
|
const RE_TWEMOJI = /:(\w+):/g;
|
||||||
|
|
||||||
export default function Renderer({ content, disallowBigEmoji }: MarkdownProps) {
|
export default function Renderer({ content, disallowBigEmoji }: MarkdownProps) {
|
||||||
const client = useContext(AppContext);
|
const client = useContext(AppContext);
|
||||||
if (typeof content === "undefined") return null;
|
if (typeof content === "undefined") return null;
|
||||||
if (content.length === 0) return null;
|
if (content.length === 0) return null;
|
||||||
|
|
||||||
// We replace the message with the mention at the time of render.
|
// We replace the message with the mention at the time of render.
|
||||||
// We don't care if the mention changes.
|
// We don't care if the mention changes.
|
||||||
let newContent = content.replace(
|
let newContent = content.replace(
|
||||||
RE_MENTIONS,
|
RE_MENTIONS,
|
||||||
(sub: string, ...args: any[]) => {
|
(sub: string, ...args: any[]) => {
|
||||||
const id = args[0],
|
const id = args[0],
|
||||||
user = client.users.get(id);
|
user = client.users.get(id);
|
||||||
|
|
||||||
if (user) {
|
if (user) {
|
||||||
return `[@${user.username}](/@${id})`;
|
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 (
|
return (
|
||||||
<span
|
<span
|
||||||
className={styles.markdown}
|
className={styles.markdown}
|
||||||
dangerouslySetInnerHTML={{
|
dangerouslySetInnerHTML={{
|
||||||
__html: md.render(newContent)
|
__html: md.render(newContent),
|
||||||
}}
|
}}
|
||||||
data-large-emojis={useLargeEmojis}
|
data-large-emojis={useLargeEmojis}
|
||||||
onClick={ev => {
|
onClick={(ev) => {
|
||||||
if (ev.target) {
|
if (ev.target) {
|
||||||
let element = ev.currentTarget;
|
let element = ev.currentTarget;
|
||||||
if (element.classList.contains("spoiler")) {
|
if (element.classList.contains("spoiler")) {
|
||||||
element.classList.add("shown");
|
element.classList.add("shown");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,88 +1,96 @@
|
||||||
import IconButton from "../ui/IconButton";
|
|
||||||
import UserIcon from "../common/user/UserIcon";
|
|
||||||
import styled, { css } from "styled-components";
|
|
||||||
import { useSelf } from "../../context/revoltjs/hooks";
|
|
||||||
import { useHistory, useLocation } from "react-router";
|
|
||||||
import ConditionalLink from "../../lib/ConditionalLink";
|
|
||||||
import { Message, Group } from "@styled-icons/boxicons-solid";
|
import { Message, Group } from "@styled-icons/boxicons-solid";
|
||||||
import { LastOpened } from "../../redux/reducers/last_opened";
|
import { useHistory, useLocation } from "react-router";
|
||||||
|
import styled, { css } from "styled-components";
|
||||||
|
|
||||||
|
import ConditionalLink from "../../lib/ConditionalLink";
|
||||||
|
|
||||||
import { connectState } from "../../redux/connector";
|
import { connectState } from "../../redux/connector";
|
||||||
|
import { LastOpened } from "../../redux/reducers/last_opened";
|
||||||
|
|
||||||
|
import { useSelf } from "../../context/revoltjs/hooks";
|
||||||
|
|
||||||
|
import UserIcon from "../common/user/UserIcon";
|
||||||
|
import IconButton from "../ui/IconButton";
|
||||||
|
|
||||||
const NavigationBase = styled.div`
|
const NavigationBase = styled.div`
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
height: 50px;
|
height: 50px;
|
||||||
display: flex;
|
display: flex;
|
||||||
background: var(--secondary-background);
|
background: var(--secondary-background);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Button = styled.a<{ active: boolean }>`
|
const Button = styled.a<{ active: boolean }>`
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
||||||
> a, > div, > a > div {
|
> a,
|
||||||
width: 100%;
|
> div,
|
||||||
height: 100%;
|
> a > div {
|
||||||
}
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
${ props => props.active && css`
|
${(props) =>
|
||||||
background: var(--hover);
|
props.active &&
|
||||||
` }
|
css`
|
||||||
|
background: var(--hover);
|
||||||
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
lastOpened: LastOpened
|
lastOpened: LastOpened;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BottomNavigation({ lastOpened }: Props) {
|
export function BottomNavigation({ lastOpened }: Props) {
|
||||||
const user = useSelf();
|
const user = useSelf();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const path = useLocation().pathname;
|
const path = useLocation().pathname;
|
||||||
|
|
||||||
const channel_id = lastOpened['home'];
|
const channel_id = lastOpened["home"];
|
||||||
|
|
||||||
const friendsActive = path.startsWith("/friends");
|
const friendsActive = path.startsWith("/friends");
|
||||||
const settingsActive = path.startsWith("/settings");
|
const settingsActive = path.startsWith("/settings");
|
||||||
const homeActive = !(friendsActive || settingsActive);
|
const homeActive = !(friendsActive || settingsActive);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<NavigationBase>
|
<NavigationBase>
|
||||||
<Button active={homeActive}>
|
<Button active={homeActive}>
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (settingsActive) {
|
if (settingsActive) {
|
||||||
if (history.length > 0) {
|
if (history.length > 0) {
|
||||||
history.goBack();
|
history.goBack();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (channel_id) {
|
if (channel_id) {
|
||||||
history.push(`/channel/${channel_id}`);
|
history.push(`/channel/${channel_id}`);
|
||||||
} else {
|
} else {
|
||||||
history.push('/');
|
history.push("/");
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
<Message size={24} />
|
<Message size={24} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</Button>
|
</Button>
|
||||||
<Button active={friendsActive}>
|
<Button active={friendsActive}>
|
||||||
<ConditionalLink active={friendsActive} to="/friends">
|
<ConditionalLink active={friendsActive} to="/friends">
|
||||||
<IconButton>
|
<IconButton>
|
||||||
<Group size={25} />
|
<Group size={25} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</ConditionalLink>
|
</ConditionalLink>
|
||||||
</Button>
|
</Button>
|
||||||
<Button active={settingsActive}>
|
<Button active={settingsActive}>
|
||||||
<ConditionalLink active={settingsActive} to="/settings">
|
<ConditionalLink active={settingsActive} to="/settings">
|
||||||
<IconButton>
|
<IconButton>
|
||||||
<UserIcon target={user} size={26} status={true} />
|
<UserIcon target={user} size={26} status={true} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</ConditionalLink>
|
</ConditionalLink>
|
||||||
</Button>
|
</Button>
|
||||||
</NavigationBase>
|
</NavigationBase>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connectState(BottomNavigation, state => {
|
export default connectState(BottomNavigation, (state) => {
|
||||||
return {
|
return {
|
||||||
lastOpened: state.lastOpened
|
lastOpened: state.lastOpened,
|
||||||
}
|
};
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,32 +1,32 @@
|
||||||
import { Route, Switch } from "react-router";
|
import { Route, Switch } from "react-router";
|
||||||
import SidebarBase from "./SidebarBase";
|
|
||||||
|
|
||||||
|
import SidebarBase from "./SidebarBase";
|
||||||
|
import HomeSidebar from "./left/HomeSidebar";
|
||||||
import ServerListSidebar from "./left/ServerListSidebar";
|
import ServerListSidebar from "./left/ServerListSidebar";
|
||||||
import ServerSidebar from "./left/ServerSidebar";
|
import ServerSidebar from "./left/ServerSidebar";
|
||||||
import HomeSidebar from "./left/HomeSidebar";
|
|
||||||
|
|
||||||
export default function LeftSidebar() {
|
export default function LeftSidebar() {
|
||||||
return (
|
return (
|
||||||
<SidebarBase>
|
<SidebarBase>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/settings" />
|
<Route path="/settings" />
|
||||||
<Route path="/server/:server/channel/:channel">
|
<Route path="/server/:server/channel/:channel">
|
||||||
<ServerListSidebar />
|
<ServerListSidebar />
|
||||||
<ServerSidebar />
|
<ServerSidebar />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/server/:server">
|
<Route path="/server/:server">
|
||||||
<ServerListSidebar />
|
<ServerListSidebar />
|
||||||
<ServerSidebar />
|
<ServerSidebar />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/channel/:channel">
|
<Route path="/channel/:channel">
|
||||||
<ServerListSidebar />
|
<ServerListSidebar />
|
||||||
<HomeSidebar />
|
<HomeSidebar />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/">
|
<Route path="/">
|
||||||
<ServerListSidebar />
|
<ServerListSidebar />
|
||||||
<HomeSidebar />
|
<HomeSidebar />
|
||||||
</Route>
|
</Route>
|
||||||
</Switch>
|
</Switch>
|
||||||
</SidebarBase>
|
</SidebarBase>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
|
@ -1,19 +1,19 @@
|
||||||
import { Route, Switch } from "react-router";
|
import { Route, Switch } from "react-router";
|
||||||
import SidebarBase from "./SidebarBase";
|
|
||||||
|
|
||||||
|
import SidebarBase from "./SidebarBase";
|
||||||
import MemberSidebar from "./right/MemberSidebar";
|
import MemberSidebar from "./right/MemberSidebar";
|
||||||
|
|
||||||
export default function RightSidebar() {
|
export default function RightSidebar() {
|
||||||
return (
|
return (
|
||||||
<SidebarBase>
|
<SidebarBase>
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/server/:server/channel/:channel">
|
<Route path="/server/:server/channel/:channel">
|
||||||
<MemberSidebar />
|
<MemberSidebar />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/channel/:channel">
|
<Route path="/channel/:channel">
|
||||||
<MemberSidebar />
|
<MemberSidebar />
|
||||||
</Route>
|
</Route>
|
||||||
</Switch>
|
</Switch>
|
||||||
</SidebarBase>
|
</SidebarBase>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
|
@ -1,34 +1,38 @@
|
||||||
import styled, { css } from "styled-components";
|
import styled, { css } from "styled-components";
|
||||||
|
|
||||||
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
|
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
|
||||||
|
|
||||||
export default styled.div`
|
export default styled.div`
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const GenericSidebarBase = styled.div<{ padding?: boolean }>`
|
export const GenericSidebarBase = styled.div<{ padding?: boolean }>`
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 240px;
|
width: 240px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--secondary-background);
|
background: var(--secondary-background);
|
||||||
border-end-start-radius: 8px;
|
border-end-start-radius: 8px;
|
||||||
|
|
||||||
${ props => props.padding && isTouchscreenDevice && css`
|
${(props) =>
|
||||||
padding-bottom: 50px;
|
props.padding &&
|
||||||
` }
|
isTouchscreenDevice &&
|
||||||
|
css`
|
||||||
|
padding-bottom: 50px;
|
||||||
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const GenericSidebarList = styled.div`
|
export const GenericSidebarList = styled.div`
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
|
|
||||||
> img {
|
> img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -1,158 +1,223 @@
|
||||||
import classNames from 'classnames';
|
|
||||||
import styles from "./Item.module.scss";
|
|
||||||
import Tooltip from '../../common/Tooltip';
|
|
||||||
import IconButton from '../../ui/IconButton';
|
|
||||||
import { Localizer, Text } from "preact-i18n";
|
|
||||||
import { X, Crown } from "@styled-icons/boxicons-regular";
|
import { X, Crown } from "@styled-icons/boxicons-regular";
|
||||||
import { Children } from "../../../types/Preact";
|
|
||||||
import UserIcon from '../../common/user/UserIcon';
|
|
||||||
import ChannelIcon from '../../common/ChannelIcon';
|
|
||||||
import UserStatus from '../../common/user/UserStatus';
|
|
||||||
import { attachContextMenu } from 'preact-context-menu';
|
|
||||||
import { Channels, Users } from "revolt.js/dist/api/objects";
|
import { Channels, Users } from "revolt.js/dist/api/objects";
|
||||||
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
|
|
||||||
import { useIntermediate } from '../../../context/intermediate/Intermediate';
|
|
||||||
import { stopPropagation } from '../../../lib/stopPropagation';
|
|
||||||
|
|
||||||
type CommonProps = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'children' | 'as'> & {
|
import styles from "./Item.module.scss";
|
||||||
active?: boolean
|
import classNames from "classnames";
|
||||||
alert?: 'unread' | 'mention'
|
import { attachContextMenu } from "preact-context-menu";
|
||||||
alertCount?: number
|
import { Localizer, Text } from "preact-i18n";
|
||||||
}
|
|
||||||
|
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
|
||||||
|
import { stopPropagation } from "../../../lib/stopPropagation";
|
||||||
|
|
||||||
|
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
||||||
|
|
||||||
|
import ChannelIcon from "../../common/ChannelIcon";
|
||||||
|
import Tooltip from "../../common/Tooltip";
|
||||||
|
import UserIcon from "../../common/user/UserIcon";
|
||||||
|
import UserStatus from "../../common/user/UserStatus";
|
||||||
|
import IconButton from "../../ui/IconButton";
|
||||||
|
|
||||||
|
import { Children } from "../../../types/Preact";
|
||||||
|
|
||||||
|
type CommonProps = Omit<
|
||||||
|
JSX.HTMLAttributes<HTMLDivElement>,
|
||||||
|
"children" | "as"
|
||||||
|
> & {
|
||||||
|
active?: boolean;
|
||||||
|
alert?: "unread" | "mention";
|
||||||
|
alertCount?: number;
|
||||||
|
};
|
||||||
|
|
||||||
type UserProps = CommonProps & {
|
type UserProps = CommonProps & {
|
||||||
user: Users.User,
|
user: Users.User;
|
||||||
context?: Channels.Channel,
|
context?: Channels.Channel;
|
||||||
channel?: Channels.DirectMessageChannel
|
channel?: Channels.DirectMessageChannel;
|
||||||
}
|
};
|
||||||
|
|
||||||
export function UserButton(props: UserProps) {
|
export function UserButton(props: UserProps) {
|
||||||
const { active, alert, alertCount, user, context, channel, ...divProps } = props;
|
const { active, alert, alertCount, user, context, channel, ...divProps } =
|
||||||
const { openScreen } = useIntermediate();
|
props;
|
||||||
|
const { openScreen } = useIntermediate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div {...divProps}
|
<div
|
||||||
className={classNames(styles.item, styles.user)}
|
{...divProps}
|
||||||
data-active={active}
|
className={classNames(styles.item, styles.user)}
|
||||||
data-alert={typeof alert === 'string'}
|
data-active={active}
|
||||||
data-online={typeof channel !== 'undefined' || (user.online && user.status?.presence !== Users.Presence.Invisible)}
|
data-alert={typeof alert === "string"}
|
||||||
onContextMenu={attachContextMenu('Menu', {
|
data-online={
|
||||||
user: user._id,
|
typeof channel !== "undefined" ||
|
||||||
channel: channel?._id,
|
(user.online &&
|
||||||
unread: alert,
|
user.status?.presence !== Users.Presence.Invisible)
|
||||||
contextualChannel: context?._id
|
}
|
||||||
})}>
|
onContextMenu={attachContextMenu("Menu", {
|
||||||
<UserIcon className={styles.avatar} target={user} size={32} status />
|
user: user._id,
|
||||||
<div className={styles.name}>
|
channel: channel?._id,
|
||||||
<div>{user.username}</div>
|
unread: alert,
|
||||||
{
|
contextualChannel: context?._id,
|
||||||
<div className={styles.subText}>
|
})}>
|
||||||
{ channel?.last_message && alert ? (
|
<UserIcon
|
||||||
channel.last_message.short
|
className={styles.avatar}
|
||||||
) : (
|
target={user}
|
||||||
<UserStatus user={user} />
|
size={32}
|
||||||
) }
|
status
|
||||||
</div>
|
/>
|
||||||
}
|
<div className={styles.name}>
|
||||||
</div>
|
<div>{user.username}</div>
|
||||||
<div className={styles.button}>
|
{
|
||||||
{ context?.channel_type === "Group" &&
|
<div className={styles.subText}>
|
||||||
context.owner === user._id && (
|
{channel?.last_message && alert ? (
|
||||||
<Localizer>
|
channel.last_message.short
|
||||||
<Tooltip
|
) : (
|
||||||
content={
|
<UserStatus user={user} />
|
||||||
<Text id="app.main.groups.owner" />
|
)}
|
||||||
}
|
</div>
|
||||||
>
|
}
|
||||||
<Crown size={20} />
|
</div>
|
||||||
</Tooltip>
|
<div className={styles.button}>
|
||||||
</Localizer>
|
{context?.channel_type === "Group" &&
|
||||||
)}
|
context.owner === user._id && (
|
||||||
{alert && <div className={styles.alert} data-style={alert}>{ alertCount }</div>}
|
<Localizer>
|
||||||
{ !isTouchscreenDevice && channel &&
|
<Tooltip
|
||||||
<IconButton className={styles.icon}
|
content={<Text id="app.main.groups.owner" />}>
|
||||||
onClick={e => stopPropagation(e) && openScreen({ id: 'special_prompt', type: 'close_dm', target: channel })}>
|
<Crown size={20} />
|
||||||
<X size={24} />
|
</Tooltip>
|
||||||
</IconButton>
|
</Localizer>
|
||||||
}
|
)}
|
||||||
</div>
|
{alert && (
|
||||||
</div>
|
<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 & {
|
type ChannelProps = CommonProps & {
|
||||||
channel: Channels.Channel & { unread?: string },
|
channel: Channels.Channel & { unread?: string };
|
||||||
user?: Users.User
|
user?: Users.User;
|
||||||
compact?: boolean
|
compact?: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
export function ChannelButton(props: ChannelProps) {
|
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 === "SavedMessages") throw "Invalid channel type.";
|
||||||
if (channel.channel_type === 'DirectMessage') {
|
if (channel.channel_type === "DirectMessage") {
|
||||||
if (typeof user === 'undefined') throw "No user provided.";
|
if (typeof user === "undefined") throw "No user provided.";
|
||||||
return <UserButton {...{ active, alert, channel, user }} />
|
return <UserButton {...{ active, alert, channel, user }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { openScreen } = useIntermediate();
|
const { openScreen } = useIntermediate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div {...divProps}
|
<div
|
||||||
data-active={active}
|
{...divProps}
|
||||||
data-alert={typeof alert === 'string'}
|
data-active={active}
|
||||||
aria-label={{}} /*FIXME: ADD ARIA LABEL*/
|
data-alert={typeof alert === "string"}
|
||||||
className={classNames(styles.item, { [styles.compact]: compact })}
|
aria-label={{}} /*FIXME: ADD ARIA LABEL*/
|
||||||
onContextMenu={attachContextMenu('Menu', { channel: channel._id, unread: typeof channel.unread !== 'undefined' })}>
|
className={classNames(styles.item, { [styles.compact]: compact })}
|
||||||
<ChannelIcon className={styles.avatar} target={channel} size={compact ? 24 : 32} />
|
onContextMenu={attachContextMenu("Menu", {
|
||||||
<div className={styles.name}>
|
channel: channel._id,
|
||||||
<div>{channel.name}</div>
|
unread: typeof channel.unread !== "undefined",
|
||||||
{ channel.channel_type === 'Group' &&
|
})}>
|
||||||
<div className={styles.subText}>
|
<ChannelIcon
|
||||||
{(channel.last_message && alert) ? (
|
className={styles.avatar}
|
||||||
channel.last_message.short
|
target={channel}
|
||||||
) : (
|
size={compact ? 24 : 32}
|
||||||
<Text
|
/>
|
||||||
id="quantities.members"
|
<div className={styles.name}>
|
||||||
plural={channel.recipients.length}
|
<div>{channel.name}</div>
|
||||||
fields={{ count: channel.recipients.length }}
|
{channel.channel_type === "Group" && (
|
||||||
/>
|
<div className={styles.subText}>
|
||||||
)}
|
{channel.last_message && alert ? (
|
||||||
</div>
|
channel.last_message.short
|
||||||
}
|
) : (
|
||||||
</div>
|
<Text
|
||||||
<div className={styles.button}>
|
id="quantities.members"
|
||||||
{alert && <div className={styles.alert} data-style={alert}>{ alertCount }</div>}
|
plural={channel.recipients.length}
|
||||||
{!isTouchscreenDevice && channel.channel_type === "Group" && (
|
fields={{ count: channel.recipients.length }}
|
||||||
<IconButton
|
/>
|
||||||
className={styles.icon}
|
)}
|
||||||
onClick={() => openScreen({ id: 'special_prompt', type: 'leave_group', target: channel })}>
|
</div>
|
||||||
<X size={24} />
|
)}
|
||||||
</IconButton>
|
</div>
|
||||||
)}
|
<div className={styles.button}>
|
||||||
</div>
|
{alert && (
|
||||||
</div>
|
<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 & {
|
type ButtonProps = CommonProps & {
|
||||||
onClick?: () => void
|
onClick?: () => void;
|
||||||
children?: Children
|
children?: Children;
|
||||||
className?: string
|
className?: string;
|
||||||
compact?: boolean
|
compact?: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function ButtonItem(props: ButtonProps) {
|
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 (
|
return (
|
||||||
<div {...divProps}
|
<div
|
||||||
className={classNames(styles.item, { [styles.compact]: compact, [styles.normal]: !compact }, className)}
|
{...divProps}
|
||||||
onClick={onClick}
|
className={classNames(
|
||||||
data-active={active}
|
styles.item,
|
||||||
data-alert={typeof alert === 'string'}>
|
{ [styles.compact]: compact, [styles.normal]: !compact },
|
||||||
<div className={styles.content}>{ children }</div>
|
className,
|
||||||
{alert && <div className={styles.alert} data-style={alert}>{ alertCount }</div>}
|
)}
|
||||||
</div>
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,35 +1,40 @@
|
||||||
import { Text } from "preact-i18n";
|
import { Text } from "preact-i18n";
|
||||||
import Banner from "../../ui/Banner";
|
|
||||||
import { useContext } from "preact/hooks";
|
import { useContext } from "preact/hooks";
|
||||||
import { ClientStatus, StatusContext } from "../../../context/revoltjs/RevoltClient";
|
|
||||||
|
import {
|
||||||
|
ClientStatus,
|
||||||
|
StatusContext,
|
||||||
|
} from "../../../context/revoltjs/RevoltClient";
|
||||||
|
|
||||||
|
import Banner from "../../ui/Banner";
|
||||||
|
|
||||||
export default function ConnectionStatus() {
|
export default function ConnectionStatus() {
|
||||||
const status = useContext(StatusContext);
|
const status = useContext(StatusContext);
|
||||||
|
|
||||||
if (status === ClientStatus.OFFLINE) {
|
if (status === ClientStatus.OFFLINE) {
|
||||||
return (
|
return (
|
||||||
<Banner>
|
<Banner>
|
||||||
<Text id="app.special.status.offline" />
|
<Text id="app.special.status.offline" />
|
||||||
</Banner>
|
</Banner>
|
||||||
);
|
);
|
||||||
} else if (status === ClientStatus.DISCONNECTED) {
|
} else if (status === ClientStatus.DISCONNECTED) {
|
||||||
return (
|
return (
|
||||||
<Banner>
|
<Banner>
|
||||||
<Text id="app.special.status.disconnected" />
|
<Text id="app.special.status.disconnected" />
|
||||||
</Banner>
|
</Banner>
|
||||||
);
|
);
|
||||||
} else if (status === ClientStatus.CONNECTING) {
|
} else if (status === ClientStatus.CONNECTING) {
|
||||||
return (
|
return (
|
||||||
<Banner>
|
<Banner>
|
||||||
<Text id="app.special.status.connecting" />
|
<Text id="app.special.status.connecting" />
|
||||||
</Banner>
|
</Banner>
|
||||||
);
|
);
|
||||||
} else if (status === ClientStatus.RECONNECTING) {
|
} else if (status === ClientStatus.RECONNECTING) {
|
||||||
return (
|
return (
|
||||||
<Banner>
|
<Banner>
|
||||||
<Text id="app.special.status.reconnecting" />
|
<Text id="app.special.status.reconnecting" />
|
||||||
</Banner>
|
</Banner>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,152 +1,193 @@
|
||||||
|
import {
|
||||||
|
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";
|
||||||
|
import { Users as UsersNS } from "revolt.js/dist/api/objects";
|
||||||
|
|
||||||
import { Text } from "preact-i18n";
|
import { Text } from "preact-i18n";
|
||||||
import { useContext, useEffect } from "preact/hooks";
|
import { useContext, useEffect } from "preact/hooks";
|
||||||
import { Home, UserDetail, Wrench, Notepad } from "@styled-icons/boxicons-solid";
|
|
||||||
|
|
||||||
import Category from '../../ui/Category';
|
|
||||||
import { dispatch } from "../../../redux";
|
|
||||||
import PaintCounter from "../../../lib/PaintCounter";
|
|
||||||
import UserHeader from "../../common/user/UserHeader";
|
|
||||||
import { Channels } from "revolt.js/dist/api/objects";
|
|
||||||
import { connectState } from "../../../redux/connector";
|
|
||||||
import ConnectionStatus from '../items/ConnectionStatus';
|
|
||||||
import { Unreads } from "../../../redux/reducers/unreads";
|
|
||||||
import ConditionalLink from "../../../lib/ConditionalLink";
|
import ConditionalLink from "../../../lib/ConditionalLink";
|
||||||
import { mapChannelWithUnread, useUnreads } from "./common";
|
import PaintCounter from "../../../lib/PaintCounter";
|
||||||
import { Users as UsersNS } from 'revolt.js/dist/api/objects';
|
|
||||||
import ButtonItem, { ChannelButton } from '../items/ButtonItem';
|
|
||||||
import { AppContext } from "../../../context/revoltjs/RevoltClient";
|
|
||||||
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
|
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
|
||||||
import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase";
|
|
||||||
import { Link, Redirect, useLocation, useParams } from "react-router-dom";
|
|
||||||
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
|
||||||
import { useDMs, useForceUpdate, useUsers } from "../../../context/revoltjs/hooks";
|
|
||||||
|
|
||||||
|
import { dispatch } from "../../../redux";
|
||||||
|
import { connectState } from "../../../redux/connector";
|
||||||
|
import { Unreads } from "../../../redux/reducers/unreads";
|
||||||
|
|
||||||
|
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
||||||
|
import { AppContext } from "../../../context/revoltjs/RevoltClient";
|
||||||
|
import {
|
||||||
|
useDMs,
|
||||||
|
useForceUpdate,
|
||||||
|
useUsers,
|
||||||
|
} from "../../../context/revoltjs/hooks";
|
||||||
|
|
||||||
|
import UserHeader from "../../common/user/UserHeader";
|
||||||
|
import Category from "../../ui/Category";
|
||||||
import placeholderSVG from "../items/placeholder.svg";
|
import placeholderSVG from "../items/placeholder.svg";
|
||||||
|
import { mapChannelWithUnread, useUnreads } from "./common";
|
||||||
|
|
||||||
|
import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase";
|
||||||
|
import ButtonItem, { ChannelButton } from "../items/ButtonItem";
|
||||||
|
import ConnectionStatus from "../items/ConnectionStatus";
|
||||||
|
|
||||||
type Props = {
|
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 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 });
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!channel) return;
|
|
||||||
|
|
||||||
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 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));
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function HomeSidebar(props: Props) {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const client = useContext(AppContext);
|
||||||
|
const { channel } = useParams<{ channel: string }>();
|
||||||
|
const { openScreen } = useIntermediate();
|
||||||
|
|
||||||
|
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 });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!channel) return;
|
||||||
|
|
||||||
|
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 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));
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default connectState(
|
export default connectState(
|
||||||
HomeSidebar,
|
HomeSidebar,
|
||||||
state => {
|
(state) => {
|
||||||
return {
|
return {
|
||||||
unreads: state.unreads
|
unreads: state.unreads,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
true
|
true,
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,257 +1,298 @@
|
||||||
|
import { Plus } from "@styled-icons/boxicons-regular";
|
||||||
|
import { useLocation, useParams } from "react-router-dom";
|
||||||
|
import { Channel, Servers } from "revolt.js/dist/api/objects";
|
||||||
|
import styled, { css } from "styled-components";
|
||||||
|
|
||||||
|
import { attachContextMenu, openContextMenu } from "preact-context-menu";
|
||||||
|
|
||||||
|
import ConditionalLink from "../../../lib/ConditionalLink";
|
||||||
|
import PaintCounter from "../../../lib/PaintCounter";
|
||||||
|
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
|
||||||
|
|
||||||
|
import { connectState } from "../../../redux/connector";
|
||||||
|
import { LastOpened } from "../../../redux/reducers/last_opened";
|
||||||
|
import { Unreads } from "../../../redux/reducers/unreads";
|
||||||
|
|
||||||
|
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
||||||
|
import {
|
||||||
|
useChannels,
|
||||||
|
useForceUpdate,
|
||||||
|
useSelf,
|
||||||
|
useServers,
|
||||||
|
} from "../../../context/revoltjs/hooks";
|
||||||
|
|
||||||
|
import ServerIcon from "../../common/ServerIcon";
|
||||||
import Tooltip from "../../common/Tooltip";
|
import Tooltip from "../../common/Tooltip";
|
||||||
|
import UserIcon from "../../common/user/UserIcon";
|
||||||
import IconButton from "../../ui/IconButton";
|
import IconButton from "../../ui/IconButton";
|
||||||
import LineDivider from "../../ui/LineDivider";
|
import LineDivider from "../../ui/LineDivider";
|
||||||
import { mapChannelWithUnread } from "./common";
|
import { mapChannelWithUnread } from "./common";
|
||||||
import styled, { css } from "styled-components";
|
|
||||||
import ServerIcon from "../../common/ServerIcon";
|
|
||||||
import { Children } from "../../../types/Preact";
|
|
||||||
import UserIcon from "../../common/user/UserIcon";
|
|
||||||
import PaintCounter from "../../../lib/PaintCounter";
|
|
||||||
import { Plus } from "@styled-icons/boxicons-regular";
|
|
||||||
import { connectState } from "../../../redux/connector";
|
|
||||||
import { useLocation, useParams } from "react-router-dom";
|
|
||||||
import { Unreads } from "../../../redux/reducers/unreads";
|
|
||||||
import ConditionalLink from "../../../lib/ConditionalLink";
|
|
||||||
import { Channel, Servers } from "revolt.js/dist/api/objects";
|
|
||||||
import { LastOpened } from "../../../redux/reducers/last_opened";
|
|
||||||
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
|
|
||||||
import { attachContextMenu, openContextMenu } from 'preact-context-menu';
|
|
||||||
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
|
||||||
import { useChannels, useForceUpdate, useSelf, useServers } from "../../../context/revoltjs/hooks";
|
|
||||||
|
|
||||||
function Icon({ children, unread, size }: { children: Children, unread?: 'mention' | 'unread', size: number }) {
|
import { Children } from "../../../types/Preact";
|
||||||
return (
|
|
||||||
<svg
|
function Icon({
|
||||||
width={size}
|
children,
|
||||||
height={size}
|
unread,
|
||||||
aria-hidden="true"
|
size,
|
||||||
viewBox="0 0 32 32"
|
}: {
|
||||||
>
|
children: Children;
|
||||||
<use href="#serverIndicator" />
|
unread?: "mention" | "unread";
|
||||||
<foreignObject x="0" y="0" width="32" height="32" mask={ unread ? "url(#server)" : undefined }>
|
size: number;
|
||||||
{ children }
|
}) {
|
||||||
</foreignObject>
|
return (
|
||||||
{unread === 'unread' && (
|
<svg width={size} height={size} aria-hidden="true" viewBox="0 0 32 32">
|
||||||
<circle
|
<use href="#serverIndicator" />
|
||||||
cx="27"
|
<foreignObject
|
||||||
cy="5"
|
x="0"
|
||||||
r="5"
|
y="0"
|
||||||
fill={"white"}
|
width="32"
|
||||||
/>
|
height="32"
|
||||||
)}
|
mask={unread ? "url(#server)" : undefined}>
|
||||||
{unread === 'mention' && (
|
{children}
|
||||||
<circle
|
</foreignObject>
|
||||||
cx="27"
|
{unread === "unread" && (
|
||||||
cy="5"
|
<circle cx="27" cy="5" r="5" fill={"white"} />
|
||||||
r="5"
|
)}
|
||||||
fill={"red"}
|
{unread === "mention" && (
|
||||||
/>
|
<circle cx="27" cy="5" r="5" fill={"red"} />
|
||||||
)}
|
)}
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ServersBase = styled.div`
|
const ServersBase = styled.div`
|
||||||
width: 56px;
|
width: 56px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
${ isTouchscreenDevice && css`
|
${isTouchscreenDevice &&
|
||||||
padding-bottom: 50px;
|
css`
|
||||||
` }
|
padding-bottom: 50px;
|
||||||
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ServerList = styled.div`
|
const ServerList = styled.div`
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
padding-bottom: 48px;
|
padding-bottom: 48px;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
// border-right: 2px solid var(--accent);
|
// border-right: 2px solid var(--accent);
|
||||||
|
|
||||||
scrollbar-width: none;
|
scrollbar-width: none;
|
||||||
|
|
||||||
> :first-child > svg {
|
> :first-child > svg {
|
||||||
margin: 6px 0 6px 4px;
|
margin: 6px 0 6px 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
width: 0px;
|
width: 0px;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ServerEntry = styled.div<{ active: boolean, home?: boolean }>`
|
const ServerEntry = styled.div<{ active: boolean; home?: boolean }>`
|
||||||
height: 58px;
|
height: 58px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
|
||||||
> * {
|
> * {
|
||||||
// outline: 1px solid red;
|
// outline: 1px solid red;
|
||||||
}
|
}
|
||||||
|
|
||||||
> div {
|
> div {
|
||||||
width: 46px;
|
width: 46px;
|
||||||
height: 46px;
|
height: 46px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
|
||||||
border-start-start-radius: 50%;
|
|
||||||
border-end-start-radius: 50%;
|
|
||||||
|
|
||||||
&:active {
|
border-start-start-radius: 50%;
|
||||||
transform: translateY(1px);
|
border-end-start-radius: 50%;
|
||||||
}
|
|
||||||
|
|
||||||
${ props => props.active && css`
|
&:active {
|
||||||
background: var(--sidebar-active);
|
transform: translateY(1px);
|
||||||
&:active {
|
}
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
` }
|
|
||||||
}
|
|
||||||
|
|
||||||
span {
|
${(props) =>
|
||||||
width: 6px;
|
props.active &&
|
||||||
height: 46px;
|
css`
|
||||||
|
background: var(--sidebar-active);
|
||||||
|
&:active {
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
}
|
||||||
|
|
||||||
${ props => props.active && css`
|
span {
|
||||||
background-color: var(--sidebar-active);
|
width: 6px;
|
||||||
|
height: 46px;
|
||||||
|
|
||||||
&::before, &::after {
|
${(props) =>
|
||||||
// outline: 1px solid blue;
|
props.active &&
|
||||||
}
|
css`
|
||||||
|
background-color: var(--sidebar-active);
|
||||||
|
|
||||||
&::before, &::after {
|
&::before,
|
||||||
content: "";
|
&::after {
|
||||||
display: block;
|
// outline: 1px solid blue;
|
||||||
position: relative;
|
}
|
||||||
|
|
||||||
width: 31px;
|
&::before,
|
||||||
height: 72px;
|
&::after {
|
||||||
margin-top: -72px;
|
content: "";
|
||||||
margin-left: -25px;
|
display: block;
|
||||||
z-index: -1;
|
position: relative;
|
||||||
|
|
||||||
background-color: var(--background);
|
width: 31px;
|
||||||
border-bottom-right-radius: 32px;
|
height: 72px;
|
||||||
|
margin-top: -72px;
|
||||||
|
margin-left: -25px;
|
||||||
|
z-index: -1;
|
||||||
|
|
||||||
box-shadow: 0 32px 0 0 var(--sidebar-active);
|
background-color: var(--background);
|
||||||
}
|
border-bottom-right-radius: 32px;
|
||||||
|
|
||||||
&::after {
|
box-shadow: 0 32px 0 0 var(--sidebar-active);
|
||||||
transform: scaleY(-1) translateY(-118px);
|
}
|
||||||
}
|
|
||||||
` }
|
|
||||||
}
|
|
||||||
|
|
||||||
${ props => (!props.active || props.home) && css`
|
&::after {
|
||||||
cursor: pointer;
|
transform: scaleY(-1) translateY(-118px);
|
||||||
` }
|
}
|
||||||
|
`}
|
||||||
|
}
|
||||||
|
|
||||||
|
${(props) =>
|
||||||
|
(!props.active || props.home) &&
|
||||||
|
css`
|
||||||
|
cursor: pointer;
|
||||||
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
unreads: Unreads;
|
unreads: Unreads;
|
||||||
lastOpened: LastOpened;
|
lastOpened: LastOpened;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServerListSidebar({ unreads, lastOpened }: Props) {
|
export function ServerListSidebar({ unreads, lastOpened }: Props) {
|
||||||
const ctx = useForceUpdate();
|
const ctx = useForceUpdate();
|
||||||
const self = useSelf(ctx);
|
const self = useSelf(ctx);
|
||||||
const activeServers = useServers(undefined, ctx) as Servers.Server[];
|
const activeServers = useServers(undefined, ctx) as Servers.Server[];
|
||||||
const channels = (useChannels(undefined, ctx) as Channel[])
|
const channels = (useChannels(undefined, ctx) as Channel[]).map((x) =>
|
||||||
.map(x => mapChannelWithUnread(x, unreads));
|
mapChannelWithUnread(x, unreads),
|
||||||
|
);
|
||||||
const unreadChannels = channels.filter(x => x.unread)
|
|
||||||
.map(x => x._id);
|
|
||||||
|
|
||||||
const servers = activeServers.map(server => {
|
const unreadChannels = channels.filter((x) => x.unread).map((x) => x._id);
|
||||||
let alertCount = 0;
|
|
||||||
for (let id of server.channels) {
|
|
||||||
let channel = channels.find(x => x._id === id);
|
|
||||||
if (channel?.alertCount) {
|
|
||||||
alertCount += channel.alertCount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
const servers = activeServers.map((server) => {
|
||||||
...server,
|
let alertCount = 0;
|
||||||
unread: (typeof server.channels.find(x => unreadChannels.includes(x)) !== 'undefined' ?
|
for (let id of server.channels) {
|
||||||
( alertCount > 0 ? 'mention' : 'unread' ) : undefined) as 'mention' | 'unread' | undefined,
|
let channel = channels.find((x) => x._id === id);
|
||||||
alertCount
|
if (channel?.alertCount) {
|
||||||
}
|
alertCount += channel.alertCount;
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const path = useLocation().pathname;
|
return {
|
||||||
const { server: server_id } = useParams<{ server?: string }>();
|
...server,
|
||||||
const server = servers.find(x => x!._id == server_id);
|
unread: (typeof server.channels.find((x) =>
|
||||||
|
unreadChannels.includes(x),
|
||||||
|
) !== "undefined"
|
||||||
|
? alertCount > 0
|
||||||
|
? "mention"
|
||||||
|
: "unread"
|
||||||
|
: undefined) as "mention" | "unread" | undefined,
|
||||||
|
alertCount,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const { openScreen } = useIntermediate();
|
const path = useLocation().pathname;
|
||||||
|
const { server: server_id } = useParams<{ server?: string }>();
|
||||||
|
const server = servers.find((x) => x!._id == server_id);
|
||||||
|
|
||||||
let homeUnread: 'mention' | 'unread' | undefined;
|
const { openScreen } = useIntermediate();
|
||||||
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';
|
let homeUnread: "mention" | "unread" | undefined;
|
||||||
const homeActive = typeof server === 'undefined' && !path.startsWith('/invite');
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
if (alertCount > 0) homeUnread = "mention";
|
||||||
<ServersBase>
|
const homeActive =
|
||||||
<ServerList>
|
typeof server === "undefined" && !path.startsWith("/invite");
|
||||||
<ConditionalLink active={homeActive} to={lastOpened.home ? `/channel/${lastOpened.home}` : '/'}>
|
|
||||||
<ServerEntry home active={homeActive}>
|
return (
|
||||||
<div onContextMenu={attachContextMenu('Status')}
|
<ServersBase>
|
||||||
onClick={() => homeActive && openContextMenu("Status")}>
|
<ServerList>
|
||||||
<Icon size={42} unread={homeUnread}>
|
<ConditionalLink
|
||||||
<UserIcon target={self} size={32} status />
|
active={homeActive}
|
||||||
</Icon>
|
to={lastOpened.home ? `/channel/${lastOpened.home}` : "/"}>
|
||||||
</div>
|
<ServerEntry home active={homeActive}>
|
||||||
<span />
|
<div
|
||||||
</ServerEntry>
|
onContextMenu={attachContextMenu("Status")}
|
||||||
</ConditionalLink>
|
onClick={() =>
|
||||||
<LineDivider />
|
homeActive && openContextMenu("Status")
|
||||||
{
|
}>
|
||||||
servers.map(entry => {
|
<Icon size={42} unread={homeUnread}>
|
||||||
const active = entry!._id === server?._id;
|
<UserIcon target={self} size={32} status />
|
||||||
const id = lastOpened[entry!._id];
|
</Icon>
|
||||||
|
</div>
|
||||||
return (
|
<span />
|
||||||
<ConditionalLink active={active} to={`/server/${entry!._id}` + (id ? `/channel/${id}` : '')}>
|
</ServerEntry>
|
||||||
<ServerEntry
|
</ConditionalLink>
|
||||||
active={active}
|
<LineDivider />
|
||||||
onContextMenu={attachContextMenu('Menu', { server: entry!._id })}>
|
{servers.map((entry) => {
|
||||||
<Tooltip content={entry.name} placement="right">
|
const active = entry!._id === server?._id;
|
||||||
<Icon size={42} unread={entry.unread}>
|
const id = lastOpened[entry!._id];
|
||||||
<ServerIcon size={32} target={entry} />
|
|
||||||
</Icon>
|
return (
|
||||||
</Tooltip>
|
<ConditionalLink
|
||||||
<span />
|
active={active}
|
||||||
</ServerEntry>
|
to={
|
||||||
</ConditionalLink>
|
`/server/${entry!._id}` +
|
||||||
)
|
(id ? `/channel/${id}` : "")
|
||||||
})
|
}>
|
||||||
}
|
<ServerEntry
|
||||||
<IconButton onClick={() => openScreen({ id: 'special_input', type: 'create_server' })}>
|
active={active}
|
||||||
<Plus size={36} />
|
onContextMenu={attachContextMenu("Menu", {
|
||||||
</IconButton>
|
server: entry!._id,
|
||||||
<PaintCounter small />
|
})}>
|
||||||
</ServerList>
|
<Tooltip content={entry.name} placement="right">
|
||||||
</ServersBase>
|
<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(
|
export default connectState(ServerListSidebar, (state) => {
|
||||||
ServerListSidebar,
|
return {
|
||||||
state => {
|
unreads: state.unreads,
|
||||||
return {
|
lastOpened: state.lastOpened,
|
||||||
unreads: state.unreads,
|
};
|
||||||
lastOpened: state.lastOpened
|
});
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
|
@ -1,134 +1,150 @@
|
||||||
import { Redirect, useParams } from "react-router";
|
import { Redirect, useParams } from "react-router";
|
||||||
import { ChannelButton } from "../items/ButtonItem";
|
|
||||||
import { Channels } from "revolt.js/dist/api/objects";
|
import { Channels } from "revolt.js/dist/api/objects";
|
||||||
import { Unreads } from "../../../redux/reducers/unreads";
|
|
||||||
import { useChannels, useForceUpdate, useServer } from "../../../context/revoltjs/hooks";
|
|
||||||
import { mapChannelWithUnread, useUnreads } from "./common";
|
|
||||||
import ConnectionStatus from '../items/ConnectionStatus';
|
|
||||||
import { connectState } from "../../../redux/connector";
|
|
||||||
import PaintCounter from "../../../lib/PaintCounter";
|
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
import { attachContextMenu } from 'preact-context-menu';
|
|
||||||
import ServerHeader from "../../common/ServerHeader";
|
import { attachContextMenu } from "preact-context-menu";
|
||||||
import { useEffect } from "preact/hooks";
|
import { useEffect } from "preact/hooks";
|
||||||
import Category from "../../ui/Category";
|
|
||||||
import { dispatch } from "../../../redux";
|
|
||||||
import ConditionalLink from "../../../lib/ConditionalLink";
|
import ConditionalLink from "../../../lib/ConditionalLink";
|
||||||
|
import PaintCounter from "../../../lib/PaintCounter";
|
||||||
|
|
||||||
|
import { dispatch } from "../../../redux";
|
||||||
|
import { connectState } from "../../../redux/connector";
|
||||||
|
import { Unreads } from "../../../redux/reducers/unreads";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useChannels,
|
||||||
|
useForceUpdate,
|
||||||
|
useServer,
|
||||||
|
} from "../../../context/revoltjs/hooks";
|
||||||
|
|
||||||
import CollapsibleSection from "../../common/CollapsibleSection";
|
import CollapsibleSection from "../../common/CollapsibleSection";
|
||||||
|
import ServerHeader from "../../common/ServerHeader";
|
||||||
|
import Category from "../../ui/Category";
|
||||||
|
import { mapChannelWithUnread, useUnreads } from "./common";
|
||||||
|
|
||||||
|
import { ChannelButton } from "../items/ButtonItem";
|
||||||
|
import ConnectionStatus from "../items/ConnectionStatus";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
unreads: Unreads;
|
unreads: Unreads;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ServerBase = styled.div`
|
const ServerBase = styled.div`
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 240px;
|
width: 240px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background: var(--secondary-background);
|
background: var(--secondary-background);
|
||||||
|
|
||||||
border-start-start-radius: 8px;
|
border-start-start-radius: 8px;
|
||||||
border-end-start-radius: 8px;
|
border-end-start-radius: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ServerList = styled.div`
|
const ServerList = styled.div`
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
overflow-y: scroll;
|
overflow-y: scroll;
|
||||||
|
|
||||||
> svg {
|
> svg {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
function ServerSidebar(props: Props) {
|
function ServerSidebar(props: Props) {
|
||||||
const { server: server_id, channel: channel_id } = useParams<{ server?: string, channel?: string }>();
|
const { server: server_id, channel: channel_id } =
|
||||||
const ctx = useForceUpdate();
|
useParams<{ server?: string; channel?: string }>();
|
||||||
|
const ctx = useForceUpdate();
|
||||||
|
|
||||||
const server = useServer(server_id, ctx);
|
const server = useServer(server_id, ctx);
|
||||||
if (!server) return <Redirect to="/" />;
|
if (!server) return <Redirect to="/" />;
|
||||||
|
|
||||||
const channels = (useChannels(server.channels, ctx)
|
const channels = (
|
||||||
.filter(entry => typeof entry !== 'undefined') as Readonly<Channels.TextChannel | Channels.VoiceChannel>[])
|
useChannels(server.channels, ctx).filter(
|
||||||
.map(x => mapChannelWithUnread(x, props.unreads));
|
(entry) => typeof entry !== "undefined",
|
||||||
|
) as Readonly<Channels.TextChannel | Channels.VoiceChannel>[]
|
||||||
const channel = channels.find(x => x?._id === channel_id);
|
).map((x) => mapChannelWithUnread(x, props.unreads));
|
||||||
if (channel_id && !channel) return <Redirect to={`/server/${server_id}`} />;
|
|
||||||
if (channel) useUnreads({ ...props, channel }, ctx);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const channel = channels.find((x) => x?._id === channel_id);
|
||||||
if (!channel_id) return;
|
if (channel_id && !channel) return <Redirect to={`/server/${server_id}`} />;
|
||||||
|
if (channel) useUnreads({ ...props, channel }, ctx);
|
||||||
|
|
||||||
dispatch({
|
useEffect(() => {
|
||||||
type: 'LAST_OPENED_SET',
|
if (!channel_id) return;
|
||||||
parent: server_id!,
|
|
||||||
child: channel_id!
|
|
||||||
});
|
|
||||||
}, [ channel_id ]);
|
|
||||||
|
|
||||||
let uncategorised = new Set(server.channels);
|
dispatch({
|
||||||
let elements = [];
|
type: "LAST_OPENED_SET",
|
||||||
|
parent: server_id!,
|
||||||
function addChannel(id: string) {
|
child: channel_id!,
|
||||||
const entry = channels.find(x => x._id === id);
|
});
|
||||||
if (!entry) return;
|
}, [channel_id]);
|
||||||
|
|
||||||
const active = channel?._id === entry._id;
|
let uncategorised = new Set(server.channels);
|
||||||
|
let elements = [];
|
||||||
|
|
||||||
return (
|
function addChannel(id: string) {
|
||||||
<ConditionalLink key={entry._id} active={active} to={`/server/${server!._id}/channel/${entry._id}`}>
|
const entry = channels.find((x) => x._id === id);
|
||||||
<ChannelButton
|
if (!entry) return;
|
||||||
channel={entry}
|
|
||||||
active={active}
|
|
||||||
alert={entry.unread}
|
|
||||||
compact
|
|
||||||
/>
|
|
||||||
</ConditionalLink>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (server.categories) {
|
const active = channel?._id === entry._id;
|
||||||
for (let category of server.categories) {
|
|
||||||
let channels = [];
|
|
||||||
for (let id of category.channels) {
|
|
||||||
uncategorised.delete(id);
|
|
||||||
channels.push(addChannel(id));
|
|
||||||
}
|
|
||||||
|
|
||||||
elements.push(
|
return (
|
||||||
<CollapsibleSection
|
<ConditionalLink
|
||||||
id={`category_${category.id}`}
|
key={entry._id}
|
||||||
defaultValue
|
active={active}
|
||||||
summary={<Category text={category.title} />}>
|
to={`/server/${server!._id}/channel/${entry._id}`}>
|
||||||
{ channels }
|
<ChannelButton
|
||||||
</CollapsibleSection>
|
channel={entry}
|
||||||
);
|
active={active}
|
||||||
}
|
alert={entry.unread}
|
||||||
}
|
compact
|
||||||
|
/>
|
||||||
|
</ConditionalLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
for (let id of Array.from(uncategorised).reverse()) {
|
if (server.categories) {
|
||||||
elements.unshift(addChannel(id));
|
for (let category of server.categories) {
|
||||||
}
|
let channels = [];
|
||||||
|
for (let id of category.channels) {
|
||||||
|
uncategorised.delete(id);
|
||||||
|
channels.push(addChannel(id));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
elements.push(
|
||||||
<ServerBase>
|
<CollapsibleSection
|
||||||
<ServerHeader server={server} ctx={ctx} />
|
id={`category_${category.id}`}
|
||||||
<ConnectionStatus />
|
defaultValue
|
||||||
<ServerList onContextMenu={attachContextMenu('Menu', { server_list: server._id })}>
|
summary={<Category text={category.title} />}>
|
||||||
{ elements }
|
{channels}
|
||||||
</ServerList>
|
</CollapsibleSection>,
|
||||||
<PaintCounter small />
|
);
|
||||||
</ServerBase>
|
}
|
||||||
)
|
}
|
||||||
};
|
|
||||||
|
|
||||||
export default connectState(
|
for (let id of Array.from(uncategorised).reverse()) {
|
||||||
ServerSidebar,
|
elements.unshift(addChannel(id));
|
||||||
state => {
|
}
|
||||||
return {
|
|
||||||
unreads: state.unreads
|
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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
|
@ -1,76 +1,103 @@
|
||||||
import { Channel } from "revolt.js";
|
import { Channel } from "revolt.js";
|
||||||
import { dispatch } from "../../../redux";
|
|
||||||
import { useLayoutEffect } from "preact/hooks";
|
import { useLayoutEffect } from "preact/hooks";
|
||||||
|
|
||||||
|
import { dispatch } from "../../../redux";
|
||||||
import { Unreads } from "../../../redux/reducers/unreads";
|
import { Unreads } from "../../../redux/reducers/unreads";
|
||||||
|
|
||||||
import { HookContext, useForceUpdate } from "../../../context/revoltjs/hooks";
|
import { HookContext, useForceUpdate } from "../../../context/revoltjs/hooks";
|
||||||
|
|
||||||
type UnreadProps = {
|
type UnreadProps = {
|
||||||
channel: Channel;
|
channel: Channel;
|
||||||
unreads: Unreads;
|
unreads: Unreads;
|
||||||
}
|
};
|
||||||
|
|
||||||
export function useUnreads({ channel, unreads }: UnreadProps, context?: HookContext) {
|
export function useUnreads(
|
||||||
const ctx = useForceUpdate(context);
|
{ channel, unreads }: UnreadProps,
|
||||||
|
context?: HookContext,
|
||||||
|
) {
|
||||||
|
const ctx = useForceUpdate(context);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
function checkUnread(target?: Channel) {
|
function checkUnread(target?: Channel) {
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
if (target._id !== channel._id) return;
|
if (target._id !== channel._id) return;
|
||||||
if (target.channel_type === "SavedMessages" ||
|
if (
|
||||||
target.channel_type === "VoiceChannel") return;
|
target.channel_type === "SavedMessages" ||
|
||||||
|
target.channel_type === "VoiceChannel"
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
const unread = unreads[channel._id]?.last_id;
|
const unread = unreads[channel._id]?.last_id;
|
||||||
if (target.last_message) {
|
if (target.last_message) {
|
||||||
const message = typeof target.last_message === 'string' ? target.last_message : target.last_message._id;
|
const message =
|
||||||
if (!unread || (unread && message.localeCompare(unread) > 0)) {
|
typeof target.last_message === "string"
|
||||||
dispatch({
|
? target.last_message
|
||||||
type: "UNREADS_MARK_READ",
|
: target.last_message._id;
|
||||||
channel: channel._id,
|
if (!unread || (unread && message.localeCompare(unread) > 0)) {
|
||||||
message
|
dispatch({
|
||||||
});
|
type: "UNREADS_MARK_READ",
|
||||||
|
channel: channel._id,
|
||||||
ctx.client.req('PUT', `/channels/${channel._id}/ack/${message}` as '/channels/id/ack/id');
|
message,
|
||||||
}
|
});
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
checkUnread(channel);
|
ctx.client.req(
|
||||||
|
"PUT",
|
||||||
|
`/channels/${channel._id}/ack/${message}` as "/channels/id/ack/id",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ctx.client.channels.addListener("mutation", checkUnread);
|
checkUnread(channel);
|
||||||
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) {
|
export function mapChannelWithUnread(channel: Channel, unreads: Unreads) {
|
||||||
let last_message_id;
|
let last_message_id;
|
||||||
if (channel.channel_type === 'DirectMessage' || channel.channel_type === 'Group') {
|
if (
|
||||||
last_message_id = channel.last_message?._id;
|
channel.channel_type === "DirectMessage" ||
|
||||||
} else if (channel.channel_type === 'TextChannel') {
|
channel.channel_type === "Group"
|
||||||
last_message_id = channel.last_message;
|
) {
|
||||||
} else {
|
last_message_id = channel.last_message?._id;
|
||||||
return { ...channel, unread: undefined, alertCount: undefined, timestamp: channel._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 unread: "mention" | "unread" | undefined;
|
||||||
let alertCount: undefined | number;
|
let alertCount: undefined | number;
|
||||||
if (last_message_id && unreads) {
|
if (last_message_id && unreads) {
|
||||||
const u = unreads[channel._id];
|
const u = unreads[channel._id];
|
||||||
if (u) {
|
if (u) {
|
||||||
if (u.mentions && u.mentions.length > 0) {
|
if (u.mentions && u.mentions.length > 0) {
|
||||||
alertCount = u.mentions.length;
|
alertCount = u.mentions.length;
|
||||||
unread = 'mention';
|
unread = "mention";
|
||||||
} else if (u.last_id && last_message_id.localeCompare(u.last_id) > 0) {
|
} else if (
|
||||||
unread = 'unread';
|
u.last_id &&
|
||||||
}
|
last_message_id.localeCompare(u.last_id) > 0
|
||||||
} else {
|
) {
|
||||||
unread = 'unread';
|
unread = "unread";
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
|
unread = "unread";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...channel,
|
...channel,
|
||||||
timestamp: last_message_id ?? channel._id,
|
timestamp: last_message_id ?? channel._id,
|
||||||
unread,
|
unread,
|
||||||
alertCount
|
alertCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,37 +1,40 @@
|
||||||
import { useRenderState } from "../../../lib/renderer/Singleton";
|
import { useRenderState } from "../../../lib/renderer/Singleton";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChannelDebugInfo({ id }: Props) {
|
export function ChannelDebugInfo({ id }: Props) {
|
||||||
if (process.env.NODE_ENV !== "development") return null;
|
if (process.env.NODE_ENV !== "development") return null;
|
||||||
let view = useRenderState(id);
|
let view = useRenderState(id);
|
||||||
if (!view) return null;
|
if (!view) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<span style={{ display: "block", padding: "12px 10px 0 10px" }}>
|
<span style={{ display: "block", padding: "12px 10px 0 10px" }}>
|
||||||
<span
|
<span
|
||||||
style={{
|
style={{
|
||||||
display: "block",
|
display: "block",
|
||||||
fontSize: "12px",
|
fontSize: "12px",
|
||||||
textTransform: "uppercase",
|
textTransform: "uppercase",
|
||||||
fontWeight: "600"
|
fontWeight: "600",
|
||||||
}}
|
}}>
|
||||||
>
|
Channel Info
|
||||||
Channel Info
|
</span>
|
||||||
</span>
|
<p style={{ fontSize: "10px", userSelect: "text" }}>
|
||||||
<p style={{ fontSize: "10px", userSelect: "text" }}>
|
State: <b>{view.type}</b> <br />
|
||||||
State: <b>{ view.type }</b> <br />
|
{view.type === "RENDER" && view.messages.length > 0 && (
|
||||||
{ view.type === 'RENDER' && view.messages.length > 0 &&
|
<>
|
||||||
<>
|
Start: <b>{view.messages[0]._id}</b> <br />
|
||||||
Start: <b>{view.messages[0]._id}</b> <br />
|
End:{" "}
|
||||||
End: <b>{view.messages[view.messages.length - 1]._id}</b> <br />
|
<b>
|
||||||
At Top: <b>{view.atTop ? "Yes" : "No"}</b> <br />
|
{view.messages[view.messages.length - 1]._id}
|
||||||
At Bottom: <b>{view.atBottom ? "Yes" : "No"}</b>
|
</b>{" "}
|
||||||
</>
|
<br />
|
||||||
}
|
At Top: <b>{view.atTop ? "Yes" : "No"}</b> <br />
|
||||||
</p>
|
At Bottom: <b>{view.atBottom ? "Yes" : "No"}</b>
|
||||||
</span>
|
</>
|
||||||
);
|
)}
|
||||||
|
</p>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,45 +1,62 @@
|
||||||
|
import { useParams } from "react-router";
|
||||||
|
import { User } from "revolt.js";
|
||||||
|
import { Channels, Servers, Users } from "revolt.js/dist/api/objects";
|
||||||
|
import { ClientboundNotification } from "revolt.js/dist/websocket/notifications";
|
||||||
|
|
||||||
import { Text } from "preact-i18n";
|
import { Text } from "preact-i18n";
|
||||||
import { useContext, useEffect, useState } from "preact/hooks";
|
import { useContext, useEffect, useState } from "preact/hooks";
|
||||||
|
|
||||||
import { User } from "revolt.js";
|
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
||||||
|
import {
|
||||||
|
AppContext,
|
||||||
|
ClientStatus,
|
||||||
|
StatusContext,
|
||||||
|
} from "../../../context/revoltjs/RevoltClient";
|
||||||
|
import {
|
||||||
|
HookContext,
|
||||||
|
useChannel,
|
||||||
|
useForceUpdate,
|
||||||
|
useUsers,
|
||||||
|
} from "../../../context/revoltjs/hooks";
|
||||||
|
|
||||||
import Category from "../../ui/Category";
|
import Category from "../../ui/Category";
|
||||||
import { useParams } from "react-router";
|
import Preloader from "../../ui/Preloader";
|
||||||
|
import placeholderSVG from "../items/placeholder.svg";
|
||||||
|
|
||||||
|
import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase";
|
||||||
import { UserButton } from "../items/ButtonItem";
|
import { UserButton } from "../items/ButtonItem";
|
||||||
import { ChannelDebugInfo } from "./ChannelDebugInfo";
|
import { ChannelDebugInfo } from "./ChannelDebugInfo";
|
||||||
import { Channels, Servers, Users } from "revolt.js/dist/api/objects";
|
|
||||||
import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase";
|
|
||||||
import { useIntermediate } from "../../../context/intermediate/Intermediate";
|
|
||||||
import { ClientboundNotification } from "revolt.js/dist/websocket/notifications";
|
|
||||||
import { AppContext, ClientStatus, StatusContext } from "../../../context/revoltjs/RevoltClient";
|
|
||||||
import { HookContext, useChannel, useForceUpdate, useUsers } from "../../../context/revoltjs/hooks";
|
|
||||||
|
|
||||||
import placeholderSVG from "../items/placeholder.svg";
|
|
||||||
import Preloader from "../../ui/Preloader";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
ctx: HookContext
|
ctx: HookContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MemberSidebar(props: { channel?: Channels.Channel }) {
|
export default function MemberSidebar(props: { channel?: Channels.Channel }) {
|
||||||
const ctx = useForceUpdate();
|
const ctx = useForceUpdate();
|
||||||
const { channel: cid } = useParams<{ channel: string }>();
|
const { channel: cid } = useParams<{ channel: string }>();
|
||||||
const channel = props.channel ?? useChannel(cid, ctx);
|
const channel = props.channel ?? useChannel(cid, ctx);
|
||||||
|
|
||||||
switch (channel?.channel_type) {
|
switch (channel?.channel_type) {
|
||||||
case 'Group': return <GroupMemberSidebar channel={channel} ctx={ctx} />;
|
case "Group":
|
||||||
case 'TextChannel': return <ServerMemberSidebar channel={channel} ctx={ctx} />;
|
return <GroupMemberSidebar channel={channel} ctx={ctx} />;
|
||||||
default: return null;
|
case "TextChannel":
|
||||||
}
|
return <ServerMemberSidebar channel={channel} ctx={ctx} />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GroupMemberSidebar({ channel, ctx }: Props & { channel: Channels.GroupChannel }) {
|
export function GroupMemberSidebar({
|
||||||
const { openScreen } = useIntermediate();
|
channel,
|
||||||
const users = useUsers(undefined, ctx);
|
ctx,
|
||||||
let members = channel.recipients
|
}: Props & { channel: Channels.GroupChannel }) {
|
||||||
.map(x => users.find(y => y?._id === x))
|
const { openScreen } = useIntermediate();
|
||||||
.filter(x => typeof x !== "undefined") as User[];
|
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;
|
const voiceActive = voice.roomId === channel._id;
|
||||||
|
|
||||||
let voiceParticipants: User[] = [];
|
let voiceParticipants: User[] = [];
|
||||||
|
@ -54,24 +71,32 @@ export function GroupMemberSidebar({ channel, ctx }: Props & { channel: Channels
|
||||||
voiceParticipants.sort((a, b) => a.username.localeCompare(b.username));
|
voiceParticipants.sort((a, b) => a.username.localeCompare(b.username));
|
||||||
}*/
|
}*/
|
||||||
|
|
||||||
members.sort((a, b) => {
|
members.sort((a, b) => {
|
||||||
// ! FIXME: should probably rewrite all this code
|
// ! FIXME: should probably rewrite all this code
|
||||||
let l = +((a.online && a.status?.presence !== Users.Presence.Invisible) ?? false) | 0;
|
let l =
|
||||||
let r = +((b.online && b.status?.presence !== Users.Presence.Invisible) ?? false) | 0;
|
+(
|
||||||
|
(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;
|
let n = r - l;
|
||||||
if (n !== 0) {
|
if (n !== 0) {
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
return a.username.localeCompare(b.username);
|
return a.username.localeCompare(b.username);
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GenericSidebarBase>
|
<GenericSidebarBase>
|
||||||
<GenericSidebarList>
|
<GenericSidebarList>
|
||||||
<ChannelDebugInfo id={channel._id} />
|
<ChannelDebugInfo id={channel._id} />
|
||||||
{/*voiceActive && voiceParticipants.length !== 0 && (
|
{/*voiceActive && voiceParticipants.length !== 0 && (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<Category
|
<Category
|
||||||
type="members"
|
type="members"
|
||||||
|
@ -96,104 +121,146 @@ export function GroupMemberSidebar({ channel, ctx }: Props & { channel: Channels
|
||||||
)}
|
)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
)*/}
|
)*/}
|
||||||
{!(members.length === 0 /*&& voiceActive*/) && (
|
{!((members.length === 0) /*&& voiceActive*/) && (
|
||||||
<Category
|
<Category
|
||||||
variant="uniform"
|
variant="uniform"
|
||||||
text={
|
text={
|
||||||
<span>
|
<span>
|
||||||
<Text id="app.main.categories.members" /> —{" "}
|
<Text id="app.main.categories.members" /> —{" "}
|
||||||
{channel.recipients.length}
|
{channel.recipients.length}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{members.length === 0 && /*!voiceActive &&*/ <img src={placeholderSVG} />}
|
{members.length === 0 && (
|
||||||
{members.map(
|
/*!voiceActive &&*/ <img src={placeholderSVG} />
|
||||||
user =>
|
)}
|
||||||
user && (
|
{members.map(
|
||||||
<UserButton
|
(user) =>
|
||||||
key={user._id}
|
user && (
|
||||||
user={user}
|
<UserButton
|
||||||
context={channel}
|
key={user._id}
|
||||||
onClick={() => openScreen({ id: 'profile', user_id: user._id })} />
|
user={user}
|
||||||
)
|
context={channel}
|
||||||
)}
|
onClick={() =>
|
||||||
</GenericSidebarList>
|
openScreen({
|
||||||
</GenericSidebarBase>
|
id: "profile",
|
||||||
);
|
user_id: user._id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</GenericSidebarList>
|
||||||
|
</GenericSidebarBase>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServerMemberSidebar({ channel, ctx }: Props & { channel: Channels.TextChannel }) {
|
export function ServerMemberSidebar({
|
||||||
const [members, setMembers] = useState<Servers.Member[] | undefined>(undefined);
|
channel,
|
||||||
const users = useUsers(members?.map(x => x._id.user) ?? []).filter(x => typeof x !== 'undefined', ctx) as Users.User[];
|
ctx,
|
||||||
const { openScreen } = useIntermediate();
|
}: Props & { channel: Channels.TextChannel }) {
|
||||||
const status = useContext(StatusContext);
|
const [members, setMembers] = useState<Servers.Member[] | undefined>(
|
||||||
const client = useContext(AppContext);
|
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(() => {
|
useEffect(() => {
|
||||||
if (status === ClientStatus.ONLINE && typeof members === 'undefined') {
|
if (status === ClientStatus.ONLINE && typeof members === "undefined") {
|
||||||
client.servers.members.fetchMembers(channel.server)
|
client.servers.members
|
||||||
.then(members => setMembers(members))
|
.fetchMembers(channel.server)
|
||||||
}
|
.then((members) => setMembers(members));
|
||||||
}, [ status ]);
|
}
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
// ! FIXME: temporary code
|
// ! FIXME: temporary code
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onPacket(packet: ClientboundNotification) {
|
function onPacket(packet: ClientboundNotification) {
|
||||||
if (!members) return;
|
if (!members) return;
|
||||||
if (packet.type === 'ServerMemberJoin') {
|
if (packet.type === "ServerMemberJoin") {
|
||||||
if (packet.id !== channel.server) return;
|
if (packet.id !== channel.server) return;
|
||||||
setMembers([ ...members, { _id: { server: packet.id, user: packet.user } } ]);
|
setMembers([
|
||||||
} else if (packet.type === 'ServerMemberLeave') {
|
...members,
|
||||||
if (packet.id !== channel.server) return;
|
{ _id: { server: packet.id, user: packet.user } },
|
||||||
setMembers(members.filter(x => !(x._id.user === packet.user && x._id.server === packet.id)));
|
]);
|
||||||
}
|
} 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);
|
client.addListener("packet", onPacket);
|
||||||
return () => client.removeListener('packet', onPacket);
|
return () => client.removeListener("packet", onPacket);
|
||||||
}, [ members ]);
|
}, [members]);
|
||||||
|
|
||||||
// copy paste from above
|
// copy paste from above
|
||||||
users.sort((a, b) => {
|
users.sort((a, b) => {
|
||||||
// ! FIXME: should probably rewrite all this code
|
// ! FIXME: should probably rewrite all this code
|
||||||
let l = +((a.online && a.status?.presence !== Users.Presence.Invisible) ?? false) | 0;
|
let l =
|
||||||
let r = +((b.online && b.status?.presence !== Users.Presence.Invisible) ?? false) | 0;
|
+(
|
||||||
|
(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;
|
let n = r - l;
|
||||||
if (n !== 0) {
|
if (n !== 0) {
|
||||||
return n;
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
return a.username.localeCompare(b.username);
|
return a.username.localeCompare(b.username);
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GenericSidebarBase>
|
<GenericSidebarBase>
|
||||||
<GenericSidebarList>
|
<GenericSidebarList>
|
||||||
<ChannelDebugInfo id={channel._id} />
|
<ChannelDebugInfo id={channel._id} />
|
||||||
<Category
|
<Category
|
||||||
variant="uniform"
|
variant="uniform"
|
||||||
text={
|
text={
|
||||||
<span>
|
<span>
|
||||||
<Text id="app.main.categories.members" /> —{" "}
|
<Text id="app.main.categories.members" /> —{" "}
|
||||||
{users.length}
|
{users.length}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{!members && <Preloader type="ring" />}
|
{!members && <Preloader type="ring" />}
|
||||||
{members && users.length === 0 && <img src={placeholderSVG} />}
|
{members && users.length === 0 && <img src={placeholderSVG} />}
|
||||||
{users.map(
|
{users.map(
|
||||||
user =>
|
(user) =>
|
||||||
user && (
|
user && (
|
||||||
<UserButton
|
<UserButton
|
||||||
key={user._id}
|
key={user._id}
|
||||||
user={user}
|
user={user}
|
||||||
context={channel}
|
context={channel}
|
||||||
onClick={() => openScreen({ id: 'profile', user_id: user._id })} />
|
onClick={() =>
|
||||||
)
|
openScreen({
|
||||||
)}
|
id: "profile",
|
||||||
</GenericSidebarList>
|
user_id: user._id,
|
||||||
</GenericSidebarBase>
|
})
|
||||||
);
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</GenericSidebarList>
|
||||||
|
</GenericSidebarBase>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
|
||||||
export default styled.div`
|
export default styled.div`
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
background: var(--primary-background);
|
background: var(--primary-background);
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -1,71 +1,71 @@
|
||||||
import styled, { css } from "styled-components";
|
import styled, { css } from "styled-components";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
readonly contrast?: boolean;
|
readonly contrast?: boolean;
|
||||||
readonly error?: boolean;
|
readonly error?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default styled.button<Props>`
|
export default styled.button<Props>`
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
|
|
||||||
transition: 0.2s ease opacity;
|
transition: 0.2s ease opacity;
|
||||||
transition: 0.2s ease background-color;
|
transition: 0.2s ease background-color;
|
||||||
|
|
||||||
background: var(--primary-background);
|
background: var(--primary-background);
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
|
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: none;
|
border: none;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--secondary-header);
|
background: var(--secondary-header);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
background: var(--primary-background);
|
background: var(--primary-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
background: var(--secondary-background);
|
background: var(--secondary-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
${(props) =>
|
${(props) =>
|
||||||
props.contrast &&
|
props.contrast &&
|
||||||
css`
|
css`
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
background: var(--secondary-header);
|
background: var(--secondary-header);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--primary-header);
|
background: var(--primary-header);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
background: var(--secondary-header);
|
background: var(--secondary-header);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
background: var(--secondary-background);
|
background: var(--secondary-background);
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
|
|
||||||
${(props) =>
|
${(props) =>
|
||||||
props.error &&
|
props.error &&
|
||||||
css`
|
css`
|
||||||
color: white;
|
color: white;
|
||||||
background: var(--error);
|
background: var(--error);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
filter: brightness(1.2);
|
filter: brightness(1.2);
|
||||||
background: var(--error);
|
background: var(--error);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:disabled {
|
&:disabled {
|
||||||
background: var(--error);
|
background: var(--error);
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -1,51 +1,55 @@
|
||||||
import styled, { css } from "styled-components";
|
|
||||||
import { Children } from "../../types/Preact";
|
|
||||||
import { Plus } from "@styled-icons/boxicons-regular";
|
import { Plus } from "@styled-icons/boxicons-regular";
|
||||||
|
import styled, { css } from "styled-components";
|
||||||
|
|
||||||
const CategoryBase = styled.div<Pick<Props, 'variant'>>`
|
import { Children } from "../../types/Preact";
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
|
|
||||||
margin-top: 4px;
|
const CategoryBase = styled.div<Pick<Props, "variant">>`
|
||||||
padding: 6px 0;
|
font-size: 12px;
|
||||||
margin-bottom: 4px;
|
font-weight: 700;
|
||||||
white-space: nowrap;
|
text-transform: uppercase;
|
||||||
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
flex-direction: row;
|
|
||||||
justify-content: space-between;
|
|
||||||
|
|
||||||
svg {
|
margin-top: 4px;
|
||||||
cursor: pointer;
|
padding: 6px 0;
|
||||||
}
|
margin-bottom: 4px;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
&:first-child {
|
display: flex;
|
||||||
margin-top: 0;
|
align-items: center;
|
||||||
padding-top: 0;
|
flex-direction: row;
|
||||||
}
|
justify-content: space-between;
|
||||||
|
|
||||||
${ props => props.variant === 'uniform' && css`
|
svg {
|
||||||
padding-top: 6px;
|
cursor: pointer;
|
||||||
` }
|
}
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
${(props) =>
|
||||||
|
props.variant === "uniform" &&
|
||||||
|
css`
|
||||||
|
padding-top: 6px;
|
||||||
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'children' | 'as' | 'action'> & {
|
type Props = Omit<
|
||||||
text: Children;
|
JSX.HTMLAttributes<HTMLDivElement>,
|
||||||
action?: () => void;
|
"children" | "as" | "action"
|
||||||
variant?: 'default' | 'uniform';
|
> & {
|
||||||
}
|
text: Children;
|
||||||
|
action?: () => void;
|
||||||
|
variant?: "default" | "uniform";
|
||||||
|
};
|
||||||
|
|
||||||
export default function Category(props: Props) {
|
export default function Category(props: Props) {
|
||||||
let { text, action, ...otherProps } = props;
|
let { text, action, ...otherProps } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CategoryBase {...otherProps}>
|
<CategoryBase {...otherProps}>
|
||||||
{text}
|
{text}
|
||||||
{action && (
|
{action && <Plus size={16} onClick={action} />}
|
||||||
<Plus size={16} onClick={action} />
|
</CategoryBase>
|
||||||
)}
|
);
|
||||||
</CategoryBase>
|
}
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
|
@ -1,107 +1,108 @@
|
||||||
import { Check } from "@styled-icons/boxicons-regular";
|
import { Check } from "@styled-icons/boxicons-regular";
|
||||||
import { Children } from "../../types/Preact";
|
|
||||||
import styled, { css } from "styled-components";
|
import styled, { css } from "styled-components";
|
||||||
|
|
||||||
|
import { Children } from "../../types/Preact";
|
||||||
|
|
||||||
const CheckboxBase = styled.label`
|
const CheckboxBase = styled.label`
|
||||||
margin-top: 20px;
|
margin-top: 20px;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
transition: 0.2s ease all;
|
transition: 0.2s ease all;
|
||||||
|
|
||||||
input {
|
input {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
.check {
|
.check {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&[disabled] {
|
&[disabled] {
|
||||||
opacity: .5;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: unset;
|
background: unset;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const CheckboxContent = styled.span`
|
const CheckboxContent = styled.span`
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const CheckboxDescription = styled.span`
|
const CheckboxDescription = styled.span`
|
||||||
font-size: .75rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: var(--secondary-foreground);
|
color: var(--secondary-foreground);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Checkmark = styled.div<{ checked: boolean }>`
|
const Checkmark = styled.div<{ checked: boolean }>`
|
||||||
margin: 4px;
|
margin: 4px;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
display: grid;
|
display: grid;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
transition: 0.2s ease all;
|
transition: 0.2s ease all;
|
||||||
background: var(--secondary-background);
|
background: var(--secondary-background);
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
color: var(--secondary-background);
|
color: var(--secondary-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
${(props) =>
|
${(props) =>
|
||||||
props.checked &&
|
props.checked &&
|
||||||
css`
|
css`
|
||||||
background: var(--accent) !important;
|
background: var(--accent) !important;
|
||||||
`}
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export interface CheckboxProps {
|
export interface CheckboxProps {
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
children: Children;
|
children: Children;
|
||||||
description?: Children;
|
description?: Children;
|
||||||
onChange: (state: boolean) => void;
|
onChange: (state: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Checkbox(props: CheckboxProps) {
|
export default function Checkbox(props: CheckboxProps) {
|
||||||
return (
|
return (
|
||||||
<CheckboxBase disabled={props.disabled} className={props.className}>
|
<CheckboxBase disabled={props.disabled} className={props.className}>
|
||||||
<CheckboxContent>
|
<CheckboxContent>
|
||||||
<span>{props.children}</span>
|
<span>{props.children}</span>
|
||||||
{props.description && (
|
{props.description && (
|
||||||
<CheckboxDescription>
|
<CheckboxDescription>
|
||||||
{props.description}
|
{props.description}
|
||||||
</CheckboxDescription>
|
</CheckboxDescription>
|
||||||
)}
|
)}
|
||||||
</CheckboxContent>
|
</CheckboxContent>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={props.checked}
|
checked={props.checked}
|
||||||
onChange={() =>
|
onChange={() =>
|
||||||
!props.disabled && props.onChange(!props.checked)
|
!props.disabled && props.onChange(!props.checked)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<Checkmark checked={props.checked} className="check">
|
<Checkmark checked={props.checked} className="check">
|
||||||
<Check size={20} />
|
<Check size={20} />
|
||||||
</Checkmark>
|
</Checkmark>
|
||||||
</CheckboxBase>
|
</CheckboxBase>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,130 +1,127 @@
|
||||||
import { useRef } from "preact/hooks";
|
|
||||||
import { Check } from "@styled-icons/boxicons-regular";
|
import { Check } from "@styled-icons/boxicons-regular";
|
||||||
import { Palette } from "@styled-icons/boxicons-solid";
|
import { Palette } from "@styled-icons/boxicons-solid";
|
||||||
import styled, { css } from "styled-components";
|
import styled, { css } from "styled-components";
|
||||||
|
|
||||||
|
import { useRef } from "preact/hooks";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const presets = [
|
const presets = [
|
||||||
[
|
[
|
||||||
"#7B68EE",
|
"#7B68EE",
|
||||||
"#3498DB",
|
"#3498DB",
|
||||||
"#1ABC9C",
|
"#1ABC9C",
|
||||||
"#F1C40F",
|
"#F1C40F",
|
||||||
"#FF7F50",
|
"#FF7F50",
|
||||||
"#FD6671",
|
"#FD6671",
|
||||||
"#E91E63",
|
"#E91E63",
|
||||||
"#D468EE",
|
"#D468EE",
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
"#594CAD",
|
"#594CAD",
|
||||||
"#206694",
|
"#206694",
|
||||||
"#11806A",
|
"#11806A",
|
||||||
"#C27C0E",
|
"#C27C0E",
|
||||||
"#CD5B45",
|
"#CD5B45",
|
||||||
"#FF424F",
|
"#FF424F",
|
||||||
"#AD1457",
|
"#AD1457",
|
||||||
"#954AA8",
|
"#954AA8",
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
const SwatchesBase = styled.div`
|
const SwatchesBase = styled.div`
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
input {
|
input {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
margin-top: 44px;
|
margin-top: 44px;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Swatch = styled.div<{ type: "small" | "large"; colour: string }>`
|
const Swatch = styled.div<{ type: "small" | "large"; colour: string }>`
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
background-color: ${(props) => props.colour};
|
background-color: ${(props) => props.colour};
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
border: 3px solid var(--foreground);
|
border: 3px solid var(--foreground);
|
||||||
transition: border ease-in-out 0.07s;
|
transition: border ease-in-out 0.07s;
|
||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
${(props) =>
|
${(props) =>
|
||||||
props.type === "small"
|
props.type === "small"
|
||||||
? css`
|
? css`
|
||||||
width: 30px;
|
width: 30px;
|
||||||
height: 30px;
|
height: 30px;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
/*stroke-width: 2;*/
|
/*stroke-width: 2;*/
|
||||||
}
|
}
|
||||||
`
|
`
|
||||||
: css`
|
: css`
|
||||||
width: 68px;
|
width: 68px;
|
||||||
height: 68px;
|
height: 68px;
|
||||||
`}
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Rows = styled.div`
|
const Rows = styled.div`
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
||||||
> div {
|
> div {
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default function ColourSwatches({ value, onChange }: Props) {
|
export default function ColourSwatches({ value, onChange }: Props) {
|
||||||
const ref = useRef<HTMLInputElement>();
|
const ref = useRef<HTMLInputElement>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SwatchesBase>
|
<SwatchesBase>
|
||||||
<Swatch
|
<Swatch
|
||||||
colour={value}
|
colour={value}
|
||||||
type="large"
|
type="large"
|
||||||
onClick={() => ref.current.click()}
|
onClick={() => ref.current.click()}>
|
||||||
>
|
<Palette size={32} />
|
||||||
<Palette size={32} />
|
</Swatch>
|
||||||
</Swatch>
|
<input
|
||||||
<input
|
type="color"
|
||||||
type="color"
|
value={value}
|
||||||
value={value}
|
ref={ref}
|
||||||
ref={ref}
|
onChange={(ev) => onChange(ev.currentTarget.value)}
|
||||||
onChange={(ev) => onChange(ev.currentTarget.value)}
|
/>
|
||||||
/>
|
<Rows>
|
||||||
<Rows>
|
{presets.map((row, i) => (
|
||||||
{presets.map((row, i) => (
|
<div key={i}>
|
||||||
<div key={i}>
|
{row.map((swatch, i) => (
|
||||||
{row.map((swatch, i) => (
|
<Swatch
|
||||||
<Swatch
|
colour={swatch}
|
||||||
colour={swatch}
|
type="small"
|
||||||
type="small"
|
key={i}
|
||||||
key={i}
|
onClick={() => onChange(swatch)}>
|
||||||
onClick={() => onChange(swatch)}
|
{swatch === value && <Check size={18} />}
|
||||||
>
|
</Swatch>
|
||||||
{swatch === value && (
|
))}
|
||||||
<Check size={18} />
|
</div>
|
||||||
)}
|
))}
|
||||||
</Swatch>
|
</Rows>
|
||||||
))}
|
</SwatchesBase>
|
||||||
</div>
|
);
|
||||||
))}
|
|
||||||
</Rows>
|
|
||||||
</SwatchesBase>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,20 +1,20 @@
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
|
||||||
export default styled.select`
|
export default styled.select`
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
color: var(--secondary-foreground);
|
color: var(--secondary-foreground);
|
||||||
background: var(--secondary-background);
|
background: var(--secondary-background);
|
||||||
font-size: .875rem;
|
font-size: 0.875rem;
|
||||||
border: none;
|
border: none;
|
||||||
outline: 2px solid transparent;
|
outline: 2px solid transparent;
|
||||||
transition: outline-color 0.2s ease-in-out;
|
transition: outline-color 0.2s ease-in-out;
|
||||||
transition: box-shadow .3s;
|
transition: box-shadow 0.3s;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
box-shadow: 0 0 0 2pt var(--accent);
|
box-shadow: 0 0 0 2pt var(--accent);
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -2,47 +2,47 @@ import dayjs from "dayjs";
|
||||||
import styled, { css } from "styled-components";
|
import styled, { css } from "styled-components";
|
||||||
|
|
||||||
const Base = styled.div<{ unread?: boolean }>`
|
const Base = styled.div<{ unread?: boolean }>`
|
||||||
height: 0;
|
height: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin: 17px 12px 5px;
|
margin: 17px 12px 5px;
|
||||||
border-top: thin solid var(--tertiary-foreground);
|
border-top: thin solid var(--tertiary-foreground);
|
||||||
|
|
||||||
time {
|
time {
|
||||||
margin-top: -2px;
|
margin-top: -2px;
|
||||||
font-size: .6875rem;
|
font-size: 0.6875rem;
|
||||||
line-height: .6875rem;
|
line-height: 0.6875rem;
|
||||||
padding: 2px 5px 2px 0;
|
padding: 2px 5px 2px 0;
|
||||||
color: var(--tertiary-foreground);
|
color: var(--tertiary-foreground);
|
||||||
background: var(--primary-background);
|
background: var(--primary-background);
|
||||||
}
|
}
|
||||||
|
|
||||||
${ props => props.unread && css`
|
${(props) =>
|
||||||
border-top: thin solid var(--accent);
|
props.unread &&
|
||||||
` }
|
css`
|
||||||
|
border-top: thin solid var(--accent);
|
||||||
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const Unread = styled.div`
|
const Unread = styled.div`
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 5px 8px;
|
padding: 5px 8px;
|
||||||
border-radius: 60px;
|
border-radius: 60px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
date: Date;
|
date: Date;
|
||||||
unread?: boolean;
|
unread?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DateDivider(props: Props) {
|
export default function DateDivider(props: Props) {
|
||||||
return (
|
return (
|
||||||
<Base unread={props.unread}>
|
<Base unread={props.unread}>
|
||||||
{ props.unread && <Unread>NEW</Unread> }
|
{props.unread && <Unread>NEW</Unread>}
|
||||||
<time>
|
<time>{dayjs(props.date).format("LL")}</time>
|
||||||
{ dayjs(props.date).format("LL") }
|
</Base>
|
||||||
</time>
|
);
|
||||||
</Base>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,68 +1,74 @@
|
||||||
import styled, { css } from "styled-components";
|
import styled, { css } from "styled-components";
|
||||||
|
|
||||||
export default styled.details<{ sticky?: boolean, large?: boolean }>`
|
export default styled.details<{ sticky?: boolean; large?: boolean }>`
|
||||||
summary {
|
summary {
|
||||||
${ props => props.sticky && css`
|
${(props) =>
|
||||||
top: -1px;
|
props.sticky &&
|
||||||
z-index: 10;
|
css`
|
||||||
position: sticky;
|
top: -1px;
|
||||||
` }
|
z-index: 10;
|
||||||
|
position: sticky;
|
||||||
|
`}
|
||||||
|
|
||||||
${ props => props.large && css`
|
${(props) =>
|
||||||
/*padding: 5px 0;*/
|
props.large &&
|
||||||
background: var(--primary-background);
|
css`
|
||||||
color: var(--secondary-foreground);
|
/*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.*/
|
.padding {
|
||||||
display: flex;
|
/*TOFIX: make this applicable only for the friends list menu, DO NOT REMOVE.*/
|
||||||
align-items: center;
|
display: flex;
|
||||||
padding: 5px 0;
|
align-items: center;
|
||||||
margin: 0.8em 0px 0.4em;
|
padding: 5px 0;
|
||||||
cursor: pointer;
|
margin: 0.8em 0px 0.4em;
|
||||||
}
|
cursor: pointer;
|
||||||
` }
|
}
|
||||||
|
`}
|
||||||
|
|
||||||
outline: none;
|
outline: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
list-style: none;
|
list-style: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
transition: .2s opacity;
|
transition: 0.2s opacity;
|
||||||
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
text-transform: uppercase;
|
|
||||||
|
|
||||||
&::marker, &::-webkit-details-marker {
|
font-size: 12px;
|
||||||
display: none;
|
font-weight: 600;
|
||||||
}
|
text-transform: uppercase;
|
||||||
|
|
||||||
.title {
|
&::marker,
|
||||||
flex-grow: 1;
|
&::-webkit-details-marker {
|
||||||
margin-top: 1px;
|
display: none;
|
||||||
text-overflow: ellipsis;
|
}
|
||||||
overflow: hidden;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.padding {
|
.title {
|
||||||
display: flex;
|
flex-grow: 1;
|
||||||
align-items: center;
|
margin-top: 1px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
> svg {
|
.padding {
|
||||||
flex-shrink: 0;
|
display: flex;
|
||||||
margin-inline-end: 4px;
|
align-items: center;
|
||||||
transition: .2s ease transform;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&:not([open]) {
|
> svg {
|
||||||
summary {
|
flex-shrink: 0;
|
||||||
opacity: .7;
|
margin-inline-end: 4px;
|
||||||
}
|
transition: 0.2s ease transform;
|
||||||
|
}
|
||||||
summary svg {
|
}
|
||||||
transform: rotateZ(-90deg);
|
}
|
||||||
}
|
|
||||||
}
|
&:not([open]) {
|
||||||
|
summary {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
summary svg {
|
||||||
|
transform: rotateZ(-90deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -1,51 +1,57 @@
|
||||||
import styled, { css } from "styled-components";
|
import styled, { css } from "styled-components";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
borders?: boolean;
|
borders?: boolean;
|
||||||
background?: boolean;
|
background?: boolean;
|
||||||
placement: 'primary' | 'secondary'
|
placement: "primary" | "secondary";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default styled.div<Props>`
|
export default styled.div<Props>`
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
flex: 0 auto;
|
flex: 0 auto;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
background-size: cover !important;
|
background-size: cover !important;
|
||||||
background-position: center !important;
|
background-position: center !important;
|
||||||
background-color: var(--primary-header);
|
background-color: var(--primary-header);
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*@media only screen and (max-width: 768px) {
|
/*@media only screen and (max-width: 768px) {
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
}*/
|
}*/
|
||||||
|
|
||||||
@media (pointer: coarse) {
|
@media (pointer: coarse) {
|
||||||
height: 56px;
|
height: 56px;
|
||||||
}
|
}
|
||||||
|
|
||||||
${ props => props.background && css`
|
|
||||||
height: 120px !important;
|
|
||||||
align-items: flex-end;
|
|
||||||
|
|
||||||
text-shadow: 0px 0px 1px black;
|
${(props) =>
|
||||||
` }
|
props.background &&
|
||||||
|
css`
|
||||||
|
height: 120px !important;
|
||||||
|
align-items: flex-end;
|
||||||
|
|
||||||
${ props => props.placement === 'secondary' && css`
|
text-shadow: 0px 0px 1px black;
|
||||||
background-color: var(--secondary-header);
|
`}
|
||||||
padding: 14px;
|
|
||||||
` }
|
|
||||||
|
|
||||||
${ props => props.borders && css`
|
${(props) =>
|
||||||
border-start-start-radius: 8px;
|
props.placement === "secondary" &&
|
||||||
` }
|
css`
|
||||||
|
background-color: var(--secondary-header);
|
||||||
|
padding: 14px;
|
||||||
|
`}
|
||||||
|
|
||||||
|
${(props) =>
|
||||||
|
props.borders &&
|
||||||
|
css`
|
||||||
|
border-start-start-radius: 8px;
|
||||||
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -1,44 +1,46 @@
|
||||||
import styled, { css } from "styled-components";
|
import styled, { css } from "styled-components";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
type?: 'default' | 'circle'
|
type?: "default" | "circle";
|
||||||
}
|
}
|
||||||
|
|
||||||
const normal = `var(--secondary-foreground)`;
|
const normal = `var(--secondary-foreground)`;
|
||||||
const hover = `var(--foreground)`;
|
const hover = `var(--foreground)`;
|
||||||
|
|
||||||
export default styled.div<Props>`
|
export default styled.div<Props>`
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
display: grid;
|
display: grid;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
transition: .1s ease background-color;
|
transition: 0.1s ease background-color;
|
||||||
|
|
||||||
fill: ${normal};
|
fill: ${normal};
|
||||||
color: ${normal};
|
color: ${normal};
|
||||||
/*stroke: ${normal};*/
|
/*stroke: ${normal};*/
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: ${normal};
|
color: ${normal};
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
fill: ${hover};
|
fill: ${hover};
|
||||||
color: ${hover};
|
color: ${hover};
|
||||||
/*stroke: ${hover};*/
|
/*stroke: ${hover};*/
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: ${hover};
|
color: ${hover};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
${ props => props.type === 'circle' && css`
|
${(props) =>
|
||||||
padding: 4px;
|
props.type === "circle" &&
|
||||||
border-radius: 50%;
|
css`
|
||||||
background-color: var(--secondary-header);
|
padding: 4px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--secondary-header);
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: var(--primary-header);
|
background-color: var(--primary-header);
|
||||||
}
|
}
|
||||||
` }
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -1,39 +1,39 @@
|
||||||
import styled, { css } from "styled-components";
|
import styled, { css } from "styled-components";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
readonly contrast?: boolean;
|
readonly contrast?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default styled.input<Props>`
|
export default styled.input<Props>`
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
|
|
||||||
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;
|
|
||||||
|
|
||||||
&:hover {
|
font-family: inherit;
|
||||||
background: var(--secondary-background);
|
color: var(--foreground);
|
||||||
}
|
background: var(--primary-background);
|
||||||
|
transition: 0.2s ease background-color;
|
||||||
|
|
||||||
&:focus {
|
border: none;
|
||||||
outline: 2px solid var(--accent);
|
outline: 2px solid transparent;
|
||||||
}
|
transition: outline-color 0.2s ease-in-out;
|
||||||
|
|
||||||
${(props) =>
|
&:hover {
|
||||||
props.contrast &&
|
background: var(--secondary-background);
|
||||||
css`
|
}
|
||||||
color: var(--secondary-foreground);
|
|
||||||
background: var(--secondary-background);
|
|
||||||
|
|
||||||
&:hover {
|
&:focus {
|
||||||
background: var(--hover);
|
outline: 2px solid var(--accent);
|
||||||
}
|
}
|
||||||
`}
|
|
||||||
|
${(props) =>
|
||||||
|
props.contrast &&
|
||||||
|
css`
|
||||||
|
color: var(--secondary-foreground);
|
||||||
|
background: var(--secondary-background);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--hover);
|
||||||
|
}
|
||||||
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import styled from "styled-components";
|
import styled from "styled-components";
|
||||||
|
|
||||||
export default styled.div`
|
export default styled.div`
|
||||||
height: 0px;
|
height: 0px;
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin: 8px 10px;
|
margin: 8px 10px;
|
||||||
border-top: 1px solid var(--tertiary-foreground);
|
border-top: 1px solid var(--tertiary-foreground);
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -1,22 +1,22 @@
|
||||||
// This file must be imported and used at least once for SVG masks.
|
// This file must be imported and used at least once for SVG masks.
|
||||||
|
|
||||||
export default function Masks() {
|
export default function Masks() {
|
||||||
return (
|
return (
|
||||||
<svg width={0} height={0} style={{ position: 'fixed' }}>
|
<svg width={0} height={0} style={{ position: "fixed" }}>
|
||||||
<defs>
|
<defs>
|
||||||
<mask id="server">
|
<mask id="server">
|
||||||
<rect x="0" y="0" width="32" height="32" fill="white" />
|
<rect x="0" y="0" width="32" height="32" fill="white" />
|
||||||
<circle cx="27" cy="5" r="7" fill={"black"} />
|
<circle cx="27" cy="5" r="7" fill={"black"} />
|
||||||
</mask>
|
</mask>
|
||||||
<mask id="user">
|
<mask id="user">
|
||||||
<rect x="0" y="0" width="32" height="32" fill="white" />
|
<rect x="0" y="0" width="32" height="32" fill="white" />
|
||||||
<circle cx="27" cy="27" r="7" fill={"black"} />
|
<circle cx="27" cy="27" r="7" fill={"black"} />
|
||||||
</mask>
|
</mask>
|
||||||
<mask id="overlap">
|
<mask id="overlap">
|
||||||
<rect x="0" y="0" width="32" height="32" fill="white" />
|
<rect x="0" y="0" width="32" height="32" fill="white" />
|
||||||
<circle cx="32" cy="16" r="18" fill={"black"} />
|
<circle cx="32" cy="16" r="18" fill={"black"} />
|
||||||
</mask>
|
</mask>
|
||||||
</defs>
|
</defs>
|
||||||
</svg>
|
</svg>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,11 @@
|
||||||
import Button from "./Button";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { Children } from "../../types/Preact";
|
|
||||||
import { createPortal, useEffect } from "preact/compat";
|
|
||||||
import styled, { css, keyframes } from "styled-components";
|
import styled, { css, keyframes } from "styled-components";
|
||||||
|
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { createPortal, useEffect } from "preact/compat";
|
||||||
|
|
||||||
|
import { Children } from "../../types/Preact";
|
||||||
|
import Button from "./Button";
|
||||||
|
|
||||||
const open = keyframes`
|
const open = keyframes`
|
||||||
0% {opacity: 0;}
|
0% {opacity: 0;}
|
||||||
70% {opacity: 0;}
|
70% {opacity: 0;}
|
||||||
|
@ -17,168 +19,181 @@ const zoomIn = keyframes`
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ModalBase = styled.div`
|
const ModalBase = styled.div`
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
max-height: 100%;
|
max-height: 100%;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
|
||||||
animation-name: ${open};
|
animation-name: ${open};
|
||||||
animation-duration: 0.2s;
|
animation-duration: 0.2s;
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
background: rgba(0, 0, 0, 0.8);
|
background: rgba(0, 0, 0, 0.8);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ModalContainer = styled.div`
|
const ModalContainer = styled.div`
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
max-width: calc(100vw - 20px);
|
max-width: calc(100vw - 20px);
|
||||||
|
|
||||||
animation-name: ${zoomIn};
|
animation-name: ${zoomIn};
|
||||||
animation-duration: 0.25s;
|
animation-duration: 0.25s;
|
||||||
animation-timing-function: cubic-bezier(.3,.3,.18,1.1);
|
animation-timing-function: cubic-bezier(0.3, 0.3, 0.18, 1.1);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ModalContent = styled.div<{ [key in 'attachment' | 'noBackground' | 'border' | 'padding']?: boolean }>`
|
const ModalContent = styled.div<
|
||||||
border-radius: 8px;
|
{ [key in "attachment" | "noBackground" | "border" | "padding"]?: boolean }
|
||||||
text-overflow: ellipsis;
|
>`
|
||||||
|
border-radius: 8px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
h3 {
|
h3 {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
form {
|
form {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
${ props => !props.noBackground && css`
|
${(props) =>
|
||||||
background: var(--secondary-header);
|
!props.noBackground &&
|
||||||
` }
|
css`
|
||||||
|
background: var(--secondary-header);
|
||||||
|
`}
|
||||||
|
|
||||||
${ props => props.padding && css`
|
${(props) =>
|
||||||
padding: 1.5em;
|
props.padding &&
|
||||||
` }
|
css`
|
||||||
|
padding: 1.5em;
|
||||||
|
`}
|
||||||
|
|
||||||
${ props => props.attachment && css`
|
${(props) =>
|
||||||
border-radius: 8px 8px 0 0;
|
props.attachment &&
|
||||||
` }
|
css`
|
||||||
|
border-radius: 8px 8px 0 0;
|
||||||
|
`}
|
||||||
|
|
||||||
${ props => props.border && css`
|
${(props) =>
|
||||||
border-radius: 10px;
|
props.border &&
|
||||||
border: 2px solid var(--secondary-background);
|
css`
|
||||||
` }
|
border-radius: 10px;
|
||||||
|
border: 2px solid var(--secondary-background);
|
||||||
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const ModalActions = styled.div`
|
const ModalActions = styled.div`
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row-reverse;
|
flex-direction: row-reverse;
|
||||||
|
|
||||||
padding: 1em 1.5em;
|
padding: 1em 1.5em;
|
||||||
border-radius: 0 0 8px 8px;
|
border-radius: 0 0 8px 8px;
|
||||||
background: var(--secondary-background);
|
background: var(--secondary-background);
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export interface Action {
|
export interface Action {
|
||||||
text: Children;
|
text: Children;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
confirmation?: boolean;
|
confirmation?: boolean;
|
||||||
contrast?: boolean;
|
contrast?: boolean;
|
||||||
error?: boolean;
|
error?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children?: Children;
|
children?: Children;
|
||||||
title?: Children;
|
title?: Children;
|
||||||
|
|
||||||
disallowClosing?: boolean;
|
disallowClosing?: boolean;
|
||||||
noBackground?: boolean;
|
noBackground?: boolean;
|
||||||
dontModal?: boolean;
|
dontModal?: boolean;
|
||||||
padding?: boolean;
|
padding?: boolean;
|
||||||
|
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
actions?: Action[];
|
actions?: Action[];
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
border?: boolean;
|
border?: boolean;
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Modal(props: Props) {
|
export default function Modal(props: Props) {
|
||||||
if (!props.visible) return null;
|
if (!props.visible) return null;
|
||||||
|
|
||||||
let content = (
|
let content = (
|
||||||
<ModalContent
|
<ModalContent
|
||||||
attachment={!!props.actions}
|
attachment={!!props.actions}
|
||||||
noBackground={props.noBackground}
|
noBackground={props.noBackground}
|
||||||
border={props.border}
|
border={props.border}
|
||||||
padding={props.padding ?? !props.dontModal}>
|
padding={props.padding ?? !props.dontModal}>
|
||||||
{props.title && <h3>{props.title}</h3>}
|
{props.title && <h3>{props.title}</h3>}
|
||||||
{props.children}
|
{props.children}
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
);
|
);
|
||||||
|
|
||||||
if (props.dontModal) {
|
if (props.dontModal) {
|
||||||
return content;
|
return content;
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (props.disallowClosing) return;
|
if (props.disallowClosing) return;
|
||||||
|
|
||||||
function keyDown(e: KeyboardEvent) {
|
function keyDown(e: KeyboardEvent) {
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
props.onClose();
|
props.onClose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.body.addEventListener("keydown", keyDown);
|
document.body.addEventListener("keydown", keyDown);
|
||||||
return () => document.body.removeEventListener("keydown", keyDown);
|
return () => document.body.removeEventListener("keydown", keyDown);
|
||||||
}, [ props.disallowClosing, props.onClose ]);
|
}, [props.disallowClosing, props.onClose]);
|
||||||
|
|
||||||
let confirmationAction = props.actions?.find(action => action.confirmation);
|
let confirmationAction = props.actions?.find(
|
||||||
useEffect(() => {
|
(action) => action.confirmation,
|
||||||
if (!confirmationAction) return;
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!confirmationAction) return;
|
||||||
|
|
||||||
// ! FIXME: this may be done better if we
|
// ! FIXME: this may be done better if we
|
||||||
// ! can focus the button although that
|
// ! can focus the button although that
|
||||||
// ! doesn't seem to work...
|
// ! doesn't seem to work...
|
||||||
function keyDown(e: KeyboardEvent) {
|
function keyDown(e: KeyboardEvent) {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
confirmationAction!.onClick();
|
confirmationAction!.onClick();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.body.addEventListener("keydown", keyDown);
|
document.body.addEventListener("keydown", keyDown);
|
||||||
return () => document.body.removeEventListener("keydown", keyDown);
|
return () => document.body.removeEventListener("keydown", keyDown);
|
||||||
}, [ confirmationAction ]);
|
}, [confirmationAction]);
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<ModalBase onClick={(!props.disallowClosing && props.onClose) || undefined}>
|
<ModalBase
|
||||||
<ModalContainer onClick={e => (e.cancelBubble = true)}>
|
onClick={(!props.disallowClosing && props.onClose) || undefined}>
|
||||||
{content}
|
<ModalContainer onClick={(e) => (e.cancelBubble = true)}>
|
||||||
{props.actions && (
|
{content}
|
||||||
<ModalActions>
|
{props.actions && (
|
||||||
{props.actions.map(x => (
|
<ModalActions>
|
||||||
<Button
|
{props.actions.map((x) => (
|
||||||
contrast={x.contrast ?? true}
|
<Button
|
||||||
error={x.error ?? false}
|
contrast={x.contrast ?? true}
|
||||||
onClick={x.onClick}
|
error={x.error ?? false}
|
||||||
disabled={props.disabled}>
|
onClick={x.onClick}
|
||||||
{x.text}
|
disabled={props.disabled}>
|
||||||
</Button>
|
{x.text}
|
||||||
))}
|
</Button>
|
||||||
</ModalActions>
|
))}
|
||||||
)}
|
</ModalActions>
|
||||||
</ModalContainer>
|
)}
|
||||||
</ModalBase>,
|
</ModalContainer>
|
||||||
document.body
|
</ModalBase>,
|
||||||
);
|
document.body,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,58 +1,64 @@
|
||||||
import styled, { css } from "styled-components";
|
import styled, { css } from "styled-components";
|
||||||
import { Children } from "../../types/Preact";
|
|
||||||
import { Text } from 'preact-i18n';
|
|
||||||
|
|
||||||
type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, 'children' | 'as'> & {
|
import { Text } from "preact-i18n";
|
||||||
error?: string;
|
|
||||||
block?: boolean;
|
import { Children } from "../../types/Preact";
|
||||||
spaced?: boolean;
|
|
||||||
children?: Children;
|
type Props = Omit<JSX.HTMLAttributes<HTMLDivElement>, "children" | "as"> & {
|
||||||
type?: "default" | "subtle" | "error";
|
error?: string;
|
||||||
}
|
block?: boolean;
|
||||||
|
spaced?: boolean;
|
||||||
|
children?: Children;
|
||||||
|
type?: "default" | "subtle" | "error";
|
||||||
|
};
|
||||||
|
|
||||||
const OverlineBase = styled.div<Omit<Props, "children" | "error">>`
|
const OverlineBase = styled.div<Omit<Props, "children" | "error">>`
|
||||||
display: inline;
|
display: inline;
|
||||||
margin: 0.4em 0;
|
margin: 0.4em 0;
|
||||||
|
|
||||||
${ props => props.spaced && css`
|
${(props) =>
|
||||||
margin-top: 0.8em;
|
props.spaced &&
|
||||||
` }
|
css`
|
||||||
|
margin-top: 0.8em;
|
||||||
|
`}
|
||||||
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
text-transform: uppercase;
|
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) =>
|
||||||
props.type === "subtle" &&
|
props.block &&
|
||||||
css`
|
css`
|
||||||
font-size: 12px;
|
display: block;
|
||||||
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) {
|
export default function Overline(props: Props) {
|
||||||
return (
|
return (
|
||||||
<OverlineBase {...props}>
|
<OverlineBase {...props}>
|
||||||
{props.children}
|
{props.children}
|
||||||
{props.children && props.error && <> · </>}
|
{props.children && props.error && <> · </>}
|
||||||
{props.error && <Overline type="error">
|
{props.error && (
|
||||||
<Text id={`error.${props.error}`}>{props.error}</Text>
|
<Overline type="error">
|
||||||
</Overline>}
|
<Text id={`error.${props.error}`}>{props.error}</Text>
|
||||||
</OverlineBase>
|
</Overline>
|
||||||
);
|
)}
|
||||||
|
</OverlineBase>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -21,83 +21,83 @@ const prRing = keyframes`
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const PreloaderBase = styled.div`
|
const PreloaderBase = styled.div`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
width: 58px;
|
width: 58px;
|
||||||
display: flex;
|
display: flex;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
margin: 100px auto 0;
|
margin: 100px auto 0;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner > div {
|
.spinner > div {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
background-color: var(--tertiary-foreground);
|
background-color: var(--tertiary-foreground);
|
||||||
|
|
||||||
border-radius: 100%;
|
border-radius: 100%;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
animation: ${skSpinner} 1.4s infinite ease-in-out both;
|
animation: ${skSpinner} 1.4s infinite ease-in-out both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner div:nth-child(1) {
|
.spinner div:nth-child(1) {
|
||||||
animation-delay: -0.32s;
|
animation-delay: -0.32s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.spinner div:nth-child(2) {
|
.spinner div:nth-child(2) {
|
||||||
animation-delay: -0.16s;
|
animation-delay: -0.16s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ring {
|
.ring {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
position: relative;
|
position: relative;
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 52px;
|
height: 52px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ring div {
|
.ring div {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
margin: 8px;
|
margin: 8px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
display: block;
|
display: block;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border: 2px solid #fff;
|
border: 2px solid #fff;
|
||||||
animation: ${prRing} 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
|
animation: ${prRing} 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
|
||||||
border-color: #fff transparent transparent transparent;
|
border-color: #fff transparent transparent transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ring div:nth-child(1) {
|
.ring div:nth-child(1) {
|
||||||
animation-delay: -0.45s;
|
animation-delay: -0.45s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ring div:nth-child(2) {
|
.ring div:nth-child(2) {
|
||||||
animation-delay: -0.3s;
|
animation-delay: -0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ring div:nth-child(3) {
|
.ring div:nth-child(3) {
|
||||||
animation-delay: -0.15s;
|
animation-delay: -0.15s;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
type: 'spinner' | 'ring'
|
type: "spinner" | "ring";
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Preloader({ type }: Props) {
|
export default function Preloader({ type }: Props) {
|
||||||
return (
|
return (
|
||||||
<PreloaderBase>
|
<PreloaderBase>
|
||||||
<div class={type}>
|
<div class={type}>
|
||||||
<div />
|
<div />
|
||||||
<div />
|
<div />
|
||||||
<div />
|
<div />
|
||||||
</div>
|
</div>
|
||||||
</PreloaderBase>
|
</PreloaderBase>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,111 +1,111 @@
|
||||||
import { Children } from "../../types/Preact";
|
|
||||||
import styled, { css } from "styled-components";
|
|
||||||
import { Circle } from "@styled-icons/boxicons-regular";
|
import { Circle } from "@styled-icons/boxicons-regular";
|
||||||
|
import styled, { css } from "styled-components";
|
||||||
|
|
||||||
|
import { Children } from "../../types/Preact";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: Children;
|
children: Children;
|
||||||
description?: Children;
|
description?: Children;
|
||||||
|
|
||||||
checked: boolean;
|
checked: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onSelect: () => void;
|
onSelect: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BaseProps {
|
interface BaseProps {
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const RadioBase = styled.label<BaseProps>`
|
const RadioBase = styled.label<BaseProps>`
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
display: flex;
|
display: flex;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
transition: 0.2s ease all;
|
transition: 0.2s ease all;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--hover);
|
background: var(--hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
> input {
|
> input {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
> div {
|
> div {
|
||||||
margin: 4px;
|
margin: 4px;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
display: grid;
|
display: grid;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
background: var(--foreground);
|
background: var(--foreground);
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
/*stroke-width: 2;*/
|
/*stroke-width: 2;*/
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
${(props) =>
|
${(props) =>
|
||||||
props.selected &&
|
props.selected &&
|
||||||
css`
|
css`
|
||||||
color: white;
|
color: white;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
|
|
||||||
> div {
|
> div {
|
||||||
background: white;
|
background: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
> div svg {
|
> div svg {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const RadioDescription = styled.span<BaseProps>`
|
const RadioDescription = styled.span<BaseProps>`
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
color: var(--secondary-foreground);
|
color: var(--secondary-foreground);
|
||||||
|
|
||||||
${(props) =>
|
${(props) =>
|
||||||
props.selected &&
|
props.selected &&
|
||||||
css`
|
css`
|
||||||
color: white;
|
color: white;
|
||||||
`}
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default function Radio(props: Props) {
|
export default function Radio(props: Props) {
|
||||||
return (
|
return (
|
||||||
<RadioBase
|
<RadioBase
|
||||||
selected={props.checked}
|
selected={props.checked}
|
||||||
disabled={props.disabled}
|
disabled={props.disabled}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
!props.disabled && props.onSelect && props.onSelect()
|
!props.disabled && props.onSelect && props.onSelect()
|
||||||
}
|
}>
|
||||||
>
|
<div>
|
||||||
<div>
|
<Circle size={12} />
|
||||||
<Circle size={12} />
|
</div>
|
||||||
</div>
|
<input type="radio" checked={props.checked} />
|
||||||
<input type="radio" checked={props.checked} />
|
<span>
|
||||||
<span>
|
<span>{props.children}</span>
|
||||||
<span>{props.children}</span>
|
{props.description && (
|
||||||
{props.description && (
|
<RadioDescription selected={props.checked}>
|
||||||
<RadioDescription selected={props.checked}>
|
{props.description}
|
||||||
{props.description}
|
</RadioDescription>
|
||||||
</RadioDescription>
|
)}
|
||||||
)}
|
</span>
|
||||||
</span>
|
</RadioBase>
|
||||||
</RadioBase>
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,10 @@
|
||||||
import styled, { css } from "styled-components";
|
import styled, { css } from "styled-components";
|
||||||
|
|
||||||
export interface TextAreaProps {
|
export interface TextAreaProps {
|
||||||
code?: boolean;
|
code?: boolean;
|
||||||
padding?: number;
|
padding?: number;
|
||||||
lineHeight?: number;
|
lineHeight?: number;
|
||||||
hideBorder?: boolean;
|
hideBorder?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TEXT_AREA_BORDER_WIDTH = 2;
|
export const TEXT_AREA_BORDER_WIDTH = 2;
|
||||||
|
@ -12,37 +12,46 @@ export const DEFAULT_TEXT_AREA_PADDING = 16;
|
||||||
export const DEFAULT_LINE_HEIGHT = 20;
|
export const DEFAULT_LINE_HEIGHT = 20;
|
||||||
|
|
||||||
export default styled.textarea<TextAreaProps>`
|
export default styled.textarea<TextAreaProps>`
|
||||||
width: 100%;
|
width: 100%;
|
||||||
resize: none;
|
resize: none;
|
||||||
display: block;
|
display: block;
|
||||||
color: var(--foreground);
|
color: var(--foreground);
|
||||||
background: var(--secondary-background);
|
background: var(--secondary-background);
|
||||||
padding: ${ props => props.padding ?? DEFAULT_TEXT_AREA_PADDING }px;
|
padding: ${(props) => props.padding ?? DEFAULT_TEXT_AREA_PADDING}px;
|
||||||
line-height: ${ props => props.lineHeight ?? DEFAULT_LINE_HEIGHT }px;
|
line-height: ${(props) => props.lineHeight ?? DEFAULT_LINE_HEIGHT}px;
|
||||||
|
|
||||||
${ props => props.hideBorder && css`
|
${(props) =>
|
||||||
border: none;
|
props.hideBorder &&
|
||||||
` }
|
css`
|
||||||
|
border: none;
|
||||||
|
`}
|
||||||
|
|
||||||
${ props => !props.hideBorder && css`
|
${(props) =>
|
||||||
border-radius: 4px;
|
!props.hideBorder &&
|
||||||
transition: border-color .2s ease-in-out;
|
css`
|
||||||
border: ${TEXT_AREA_BORDER_WIDTH}px solid transparent;
|
border-radius: 4px;
|
||||||
` }
|
transition: border-color 0.2s ease-in-out;
|
||||||
|
border: ${TEXT_AREA_BORDER_WIDTH}px solid transparent;
|
||||||
|
`}
|
||||||
|
|
||||||
&:focus {
|
&:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
|
|
||||||
${ props => !props.hideBorder && css`
|
${(props) =>
|
||||||
border: ${TEXT_AREA_BORDER_WIDTH}px solid var(--accent);
|
!props.hideBorder &&
|
||||||
` }
|
css`
|
||||||
}
|
border: ${TEXT_AREA_BORDER_WIDTH}px solid var(--accent);
|
||||||
|
`}
|
||||||
|
}
|
||||||
|
|
||||||
${ props => props.code ? css`
|
${(props) =>
|
||||||
font-family: var(--monoscape-font-font), monospace;
|
props.code
|
||||||
` : css`
|
? css`
|
||||||
font-family: inherit;
|
font-family: var(--monoscape-font-font), monospace;
|
||||||
` }
|
`
|
||||||
|
: css`
|
||||||
font-variant-ligatures: var(--ligatures);
|
font-family: inherit;
|
||||||
|
`}
|
||||||
|
|
||||||
|
font-variant-ligatures: var(--ligatures);
|
||||||
`;
|
`;
|
||||||
|
|
|
@ -1,65 +1,69 @@
|
||||||
import { Children } from "../../types/Preact";
|
|
||||||
import styled, { css } from "styled-components";
|
|
||||||
import { InfoCircle } from "@styled-icons/boxicons-regular";
|
import { InfoCircle } from "@styled-icons/boxicons-regular";
|
||||||
|
import styled, { css } from "styled-components";
|
||||||
|
|
||||||
|
import { Children } from "../../types/Preact";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
warning?: boolean
|
warning?: boolean;
|
||||||
error?: boolean
|
error?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Separator = styled.div<Props>`
|
export const Separator = styled.div<Props>`
|
||||||
height: 1px;
|
height: 1px;
|
||||||
width: calc(100% - 10px);
|
width: calc(100% - 10px);
|
||||||
background: var(--secondary-header);
|
background: var(--secondary-header);
|
||||||
margin: 18px auto;
|
margin: 18px auto;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const TipBase = styled.div<Props>`
|
export const TipBase = styled.div<Props>`
|
||||||
display: flex;
|
display: flex;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
background: var(--primary-header);
|
background: var(--primary-header);
|
||||||
border: 2px solid var(--secondary-header);
|
border: 2px solid var(--secondary-header);
|
||||||
|
|
||||||
a {
|
a {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
&:hover {
|
&:hover {
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-inline-end: 10px;
|
margin-inline-end: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
${ props => props.warning && css`
|
${(props) =>
|
||||||
color: var(--warning);
|
props.warning &&
|
||||||
border: 2px solid var(--warning);
|
css`
|
||||||
background: var(--secondary-header);
|
color: var(--warning);
|
||||||
` }
|
border: 2px solid var(--warning);
|
||||||
|
background: var(--secondary-header);
|
||||||
|
`}
|
||||||
|
|
||||||
${ props => props.error && css`
|
${(props) =>
|
||||||
color: var(--error);
|
props.error &&
|
||||||
border: 2px solid var(--error);
|
css`
|
||||||
background: var(--secondary-header);
|
color: var(--error);
|
||||||
` }
|
border: 2px solid var(--error);
|
||||||
|
background: var(--secondary-header);
|
||||||
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default function Tip(props: Props & { children: Children }) {
|
export default function Tip(props: Props & { children: Children }) {
|
||||||
const { children, ...tipProps } = props;
|
const { children, ...tipProps } = props;
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Separator />
|
<Separator />
|
||||||
<TipBase {...tipProps}>
|
<TipBase {...tipProps}>
|
||||||
<InfoCircle size={20} />
|
<InfoCircle size={20} />
|
||||||
<span>{props.children}</span>
|
<span>{props.children}</span>
|
||||||
</TipBase>
|
</TipBase>
|
||||||
</>
|
</>
|
||||||
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,186 +1,217 @@
|
||||||
import { IntlProvider } from "preact-i18n";
|
|
||||||
import defaultsDeep from "lodash.defaultsdeep";
|
|
||||||
import { connectState } from "../redux/connector";
|
|
||||||
import { useEffect, useState } from "preact/hooks";
|
|
||||||
import definition from "../../external/lang/en.json";
|
|
||||||
|
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import calendar from "dayjs/plugin/calendar";
|
import calendar from "dayjs/plugin/calendar";
|
||||||
import update from "dayjs/plugin/updateLocale";
|
|
||||||
import format from "dayjs/plugin/localizedFormat";
|
import format from "dayjs/plugin/localizedFormat";
|
||||||
|
import update from "dayjs/plugin/updateLocale";
|
||||||
|
import defaultsDeep from "lodash.defaultsdeep";
|
||||||
|
|
||||||
|
import { IntlProvider } from "preact-i18n";
|
||||||
|
import { useEffect, useState } from "preact/hooks";
|
||||||
|
|
||||||
|
import { connectState } from "../redux/connector";
|
||||||
|
|
||||||
|
import definition from "../../external/lang/en.json";
|
||||||
|
|
||||||
dayjs.extend(calendar);
|
dayjs.extend(calendar);
|
||||||
dayjs.extend(format);
|
dayjs.extend(format);
|
||||||
dayjs.extend(update);
|
dayjs.extend(update);
|
||||||
|
|
||||||
export enum Language {
|
export enum Language {
|
||||||
ENGLISH = "en",
|
ENGLISH = "en",
|
||||||
|
|
||||||
ARABIC = "ar",
|
ARABIC = "ar",
|
||||||
AZERBAIJANI = "az",
|
AZERBAIJANI = "az",
|
||||||
CZECH = "cs",
|
CZECH = "cs",
|
||||||
GERMAN = "de",
|
GERMAN = "de",
|
||||||
SPANISH = "es",
|
SPANISH = "es",
|
||||||
FINNISH = "fi",
|
FINNISH = "fi",
|
||||||
FRENCH = "fr",
|
FRENCH = "fr",
|
||||||
HINDI = "hi",
|
HINDI = "hi",
|
||||||
CROATIAN = "hr",
|
CROATIAN = "hr",
|
||||||
HUNGARIAN = "hu",
|
HUNGARIAN = "hu",
|
||||||
INDONESIAN = "id",
|
INDONESIAN = "id",
|
||||||
LITHUANIAN = "lt",
|
LITHUANIAN = "lt",
|
||||||
MACEDONIAN = "mk",
|
MACEDONIAN = "mk",
|
||||||
DUTCH = "nl",
|
DUTCH = "nl",
|
||||||
POLISH = "pl",
|
POLISH = "pl",
|
||||||
PORTUGUESE_BRAZIL = "pt_BR",
|
PORTUGUESE_BRAZIL = "pt_BR",
|
||||||
ROMANIAN = "ro",
|
ROMANIAN = "ro",
|
||||||
RUSSIAN = "ru",
|
RUSSIAN = "ru",
|
||||||
SERBIAN = "sr",
|
SERBIAN = "sr",
|
||||||
SWEDISH = "sv",
|
SWEDISH = "sv",
|
||||||
TURKISH = "tr",
|
TURKISH = "tr",
|
||||||
UKRANIAN = "uk",
|
UKRANIAN = "uk",
|
||||||
CHINESE_SIMPLIFIED = "zh_Hans",
|
CHINESE_SIMPLIFIED = "zh_Hans",
|
||||||
|
|
||||||
OWO = "owo",
|
OWO = "owo",
|
||||||
PIRATE = "pr",
|
PIRATE = "pr",
|
||||||
BOTTOM = "bottom",
|
BOTTOM = "bottom",
|
||||||
PIGLATIN = "piglatin",
|
PIGLATIN = "piglatin",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LanguageEntry {
|
export interface LanguageEntry {
|
||||||
display: string;
|
display: string;
|
||||||
emoji: string;
|
emoji: string;
|
||||||
i18n: string;
|
i18n: string;
|
||||||
dayjs?: string;
|
dayjs?: string;
|
||||||
rtl?: boolean;
|
rtl?: boolean;
|
||||||
alt?: boolean;
|
alt?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Languages: { [key in Language]: LanguageEntry } = {
|
export const Languages: { [key in Language]: LanguageEntry } = {
|
||||||
en: {
|
en: {
|
||||||
display: "English (Traditional)",
|
display: "English (Traditional)",
|
||||||
emoji: "🇬🇧",
|
emoji: "🇬🇧",
|
||||||
i18n: "en",
|
i18n: "en",
|
||||||
dayjs: "en-gb",
|
dayjs: "en-gb",
|
||||||
},
|
},
|
||||||
|
|
||||||
ar: { display: "عربي", emoji: "🇸🇦", i18n: "ar", rtl: true },
|
ar: { display: "عربي", emoji: "🇸🇦", i18n: "ar", rtl: true },
|
||||||
az: { display: "Azərbaycan dili", emoji: "🇦🇿", i18n: "az" },
|
az: { display: "Azərbaycan dili", emoji: "🇦🇿", i18n: "az" },
|
||||||
cs: { display: "Čeština", emoji: "🇨🇿", i18n: "cs" },
|
cs: { display: "Čeština", emoji: "🇨🇿", i18n: "cs" },
|
||||||
de: { display: "Deutsch", emoji: "🇩🇪", i18n: "de" },
|
de: { display: "Deutsch", emoji: "🇩🇪", i18n: "de" },
|
||||||
es: { display: "Español", emoji: "🇪🇸", i18n: "es" },
|
es: { display: "Español", emoji: "🇪🇸", i18n: "es" },
|
||||||
fi: { display: "suomi", emoji: "🇫🇮", i18n: "fi" },
|
fi: { display: "suomi", emoji: "🇫🇮", i18n: "fi" },
|
||||||
fr: { display: "Français", emoji: "🇫🇷", i18n: "fr" },
|
fr: { display: "Français", emoji: "🇫🇷", i18n: "fr" },
|
||||||
hi: { display: "हिन्दी", emoji: "🇮🇳", i18n: "hi" },
|
hi: { display: "हिन्दी", emoji: "🇮🇳", i18n: "hi" },
|
||||||
hr: { display: "Hrvatski", emoji: "🇭🇷", i18n: "hr" },
|
hr: { display: "Hrvatski", emoji: "🇭🇷", i18n: "hr" },
|
||||||
hu: { display: "magyar", emoji: "🇭🇺", i18n: "hu" },
|
hu: { display: "magyar", emoji: "🇭🇺", i18n: "hu" },
|
||||||
id: { display: "bahasa Indonesia", emoji: "🇮🇩", i18n: "id" },
|
id: { display: "bahasa Indonesia", emoji: "🇮🇩", i18n: "id" },
|
||||||
lt: { display: "Lietuvių", emoji: "🇱🇹", i18n: "lt" },
|
lt: { display: "Lietuvių", emoji: "🇱🇹", i18n: "lt" },
|
||||||
mk: { display: "Македонски", emoji: "🇲🇰", i18n: "mk" },
|
mk: { display: "Македонски", emoji: "🇲🇰", i18n: "mk" },
|
||||||
nl: { display: "Nederlands", emoji: "🇳🇱", i18n: "nl" },
|
nl: { display: "Nederlands", emoji: "🇳🇱", i18n: "nl" },
|
||||||
pl: { display: "Polski", emoji: "🇵🇱", i18n: "pl" },
|
pl: { display: "Polski", emoji: "🇵🇱", i18n: "pl" },
|
||||||
pt_BR: {
|
pt_BR: {
|
||||||
display: "Português (do Brasil)",
|
display: "Português (do Brasil)",
|
||||||
emoji: "🇧🇷",
|
emoji: "🇧🇷",
|
||||||
i18n: "pt_BR",
|
i18n: "pt_BR",
|
||||||
dayjs: "pt-br",
|
dayjs: "pt-br",
|
||||||
},
|
},
|
||||||
ro: { display: "Română", emoji: "🇷🇴", i18n: "ro" },
|
ro: { display: "Română", emoji: "🇷🇴", i18n: "ro" },
|
||||||
ru: { display: "Русский", emoji: "🇷🇺", i18n: "ru" },
|
ru: { display: "Русский", emoji: "🇷🇺", i18n: "ru" },
|
||||||
sr: { display: "Српски", emoji: "🇷🇸", i18n: "sr" },
|
sr: { display: "Српски", emoji: "🇷🇸", i18n: "sr" },
|
||||||
sv: { display: "Svenska", emoji: "🇸🇪", i18n: "sv" },
|
sv: { display: "Svenska", emoji: "🇸🇪", i18n: "sv" },
|
||||||
tr: { display: "Türkçe", emoji: "🇹🇷", i18n: "tr" },
|
tr: { display: "Türkçe", emoji: "🇹🇷", i18n: "tr" },
|
||||||
uk: { display: "Українська", emoji: "🇺🇦", i18n: "uk" },
|
uk: { display: "Українська", emoji: "🇺🇦", i18n: "uk" },
|
||||||
zh_Hans: {
|
zh_Hans: {
|
||||||
display: "中文 (简体)",
|
display: "中文 (简体)",
|
||||||
emoji: "🇨🇳",
|
emoji: "🇨🇳",
|
||||||
i18n: "zh_Hans",
|
i18n: "zh_Hans",
|
||||||
dayjs: "zh",
|
dayjs: "zh",
|
||||||
},
|
},
|
||||||
|
|
||||||
owo: { display: "OwO", emoji: "🐱", i18n: "owo", dayjs: "en-gb", alt: true },
|
owo: {
|
||||||
pr: { display: "Pirate", emoji: "🏴☠️", i18n: "pr", dayjs: "en-gb", alt: true },
|
display: "OwO",
|
||||||
bottom: { display: "Bottom", emoji: "🥺", i18n: "bottom", dayjs: "en-gb", alt: true },
|
emoji: "🐱",
|
||||||
piglatin: {
|
i18n: "owo",
|
||||||
display: "Pig Latin",
|
dayjs: "en-gb",
|
||||||
emoji: "🐖",
|
alt: true,
|
||||||
i18n: "piglatin",
|
},
|
||||||
dayjs: "en-gb",
|
pr: {
|
||||||
alt: true
|
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 {
|
interface Props {
|
||||||
children: JSX.Element | JSX.Element[];
|
children: JSX.Element | JSX.Element[];
|
||||||
locale: Language;
|
locale: Language;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Locale({ children, locale }: Props) {
|
function Locale({ children, locale }: Props) {
|
||||||
// TODO: create and use LanguageDefinition type here
|
// TODO: create and use LanguageDefinition type here
|
||||||
const [defns, setDefinition] = useState<Record<string, unknown>>(definition);
|
const [defns, setDefinition] =
|
||||||
const lang = Languages[locale];
|
useState<Record<string, unknown>>(definition);
|
||||||
|
const lang = Languages[locale];
|
||||||
|
|
||||||
// TODO: clean this up and use the built in Intl API
|
// TODO: clean this up and use the built in Intl API
|
||||||
function transformLanguage(source: { [key: string]: any }) {
|
function transformLanguage(source: { [key: string]: any }) {
|
||||||
const obj = defaultsDeep(source, definition);
|
const obj = defaultsDeep(source, definition);
|
||||||
|
|
||||||
const dayjs = obj.dayjs;
|
const dayjs = obj.dayjs;
|
||||||
const defaults = dayjs.defaults;
|
const defaults = dayjs.defaults;
|
||||||
|
|
||||||
const twelvehour = defaults?.twelvehour === 'yes' || true;
|
const twelvehour = defaults?.twelvehour === "yes" || true;
|
||||||
const separator: '/' | '-' | '.' = defaults?.date_separator ?? '/';
|
const separator: "/" | "-" | "." = defaults?.date_separator ?? "/";
|
||||||
const date: 'traditional' | 'simplified' | 'ISO8601' = defaults?.date_format ?? 'traditional';
|
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'
|
|
||||||
}
|
|
||||||
|
|
||||||
dayjs['sameElse'] = DATE_FORMATS[date];
|
const DATE_FORMATS = {
|
||||||
Object.keys(dayjs)
|
traditional: `DD${separator}MM${separator}YYYY`,
|
||||||
.filter(k => k !== 'defaults')
|
simplified: `MM${separator}DD${separator}YYYY`,
|
||||||
.forEach(k => dayjs[k] = dayjs[k].replace(/{{time}}/g, twelvehour ? 'LT' : 'HH:mm'));
|
ISO8601: "YYYY-MM-DD",
|
||||||
|
};
|
||||||
return obj;
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
dayjs["sameElse"] = DATE_FORMATS[date];
|
||||||
if (locale === "en") {
|
Object.keys(dayjs)
|
||||||
const defn = transformLanguage(definition);
|
.filter((k) => k !== "defaults")
|
||||||
setDefinition(defn);
|
.forEach(
|
||||||
dayjs.locale("en");
|
(k) =>
|
||||||
dayjs.updateLocale('en', { calendar: defn.dayjs });
|
(dayjs[k] = dayjs[k].replace(
|
||||||
return;
|
/{{time}}/g,
|
||||||
}
|
twelvehour ? "LT" : "HH:mm",
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
|
||||||
import(`../../external/lang/${lang.i18n}.json`).then(
|
return obj;
|
||||||
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) {
|
useEffect(() => {
|
||||||
dayjs.updateLocale(target, { calendar: defn.dayjs });
|
if (locale === "en") {
|
||||||
}
|
const defn = transformLanguage(definition);
|
||||||
|
setDefinition(defn);
|
||||||
|
dayjs.locale("en");
|
||||||
|
dayjs.updateLocale("en", { calendar: defn.dayjs });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
dayjs.locale(dayjs_locale.default);
|
import(`../../external/lang/${lang.i18n}.json`).then(
|
||||||
setDefinition(defn);
|
async (lang_file) => {
|
||||||
}
|
const defn = transformLanguage(lang_file.default);
|
||||||
);
|
const target = lang.dayjs ?? lang.i18n;
|
||||||
}, [locale, lang]);
|
const dayjs_locale = await import(
|
||||||
|
`../../node_modules/dayjs/esm/locale/${target}.js`
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
if (defn.dayjs) {
|
||||||
document.body.style.direction = lang.rtl ? "rtl" : "";
|
dayjs.updateLocale(target, { calendar: defn.dayjs });
|
||||||
}, [lang.rtl]);
|
}
|
||||||
|
|
||||||
return <IntlProvider definition={defns}>{children}</IntlProvider>;
|
dayjs.locale(dayjs_locale.default);
|
||||||
|
setDefinition(defn);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, [locale, lang]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.body.style.direction = lang.rtl ? "rtl" : "";
|
||||||
|
}, [lang.rtl]);
|
||||||
|
|
||||||
|
return <IntlProvider definition={defns}>{children}</IntlProvider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connectState<Omit<Props, "locale">>(
|
export default connectState<Omit<Props, "locale">>(
|
||||||
Locale,
|
Locale,
|
||||||
(state) => {
|
(state) => {
|
||||||
return {
|
return {
|
||||||
locale: state.locale,
|
locale: state.locale,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
true
|
true,
|
||||||
);
|
);
|
||||||
|
|
|
@ -6,44 +6,56 @@
|
||||||
// if it does cause problems though.
|
// if it does cause problems though.
|
||||||
//
|
//
|
||||||
// This now also supports Audio stuff.
|
// This now also supports Audio stuff.
|
||||||
|
|
||||||
import { DEFAULT_SOUNDS, Settings, SoundOptions } from "../redux/reducers/settings";
|
|
||||||
import { playSound, Sounds } from "../assets/sounds/Audio";
|
|
||||||
import { connectState } from "../redux/connector";
|
|
||||||
import defaultsDeep from "lodash.defaultsdeep";
|
import defaultsDeep from "lodash.defaultsdeep";
|
||||||
import { Children } from "../types/Preact";
|
|
||||||
import { createContext } from "preact";
|
import { createContext } from "preact";
|
||||||
import { useMemo } from "preact/hooks";
|
import { useMemo } from "preact/hooks";
|
||||||
|
|
||||||
|
import { connectState } from "../redux/connector";
|
||||||
|
import {
|
||||||
|
DEFAULT_SOUNDS,
|
||||||
|
Settings,
|
||||||
|
SoundOptions,
|
||||||
|
} from "../redux/reducers/settings";
|
||||||
|
|
||||||
|
import { playSound, Sounds } from "../assets/sounds/Audio";
|
||||||
|
import { Children } from "../types/Preact";
|
||||||
|
|
||||||
export const SettingsContext = createContext<Settings>({});
|
export const SettingsContext = createContext<Settings>({});
|
||||||
export const SoundContext = createContext<((sound: Sounds) => void)>(null!);
|
export const SoundContext = createContext<(sound: Sounds) => void>(null!);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children?: Children,
|
children?: Children;
|
||||||
settings: Settings
|
settings: Settings;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Settings({ settings, children }: Props) {
|
function SettingsProvider({ settings, children }: Props) {
|
||||||
const play = useMemo(() => {
|
const play = useMemo(() => {
|
||||||
const enabled: SoundOptions = defaultsDeep(settings.notification?.sounds ?? {}, DEFAULT_SOUNDS);
|
const enabled: SoundOptions = defaultsDeep(
|
||||||
return (sound: Sounds) => {
|
settings.notification?.sounds ?? {},
|
||||||
if (enabled[sound]) {
|
DEFAULT_SOUNDS,
|
||||||
playSound(sound);
|
);
|
||||||
}
|
return (sound: Sounds) => {
|
||||||
};
|
if (enabled[sound]) {
|
||||||
}, [ settings.notification ]);
|
playSound(sound);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [settings.notification]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsContext.Provider value={settings}>
|
<SettingsContext.Provider value={settings}>
|
||||||
<SoundContext.Provider value={play}>
|
<SoundContext.Provider value={play}>
|
||||||
{ children }
|
{children}
|
||||||
</SoundContext.Provider>
|
</SoundContext.Provider>
|
||||||
</SettingsContext.Provider>
|
</SettingsContext.Provider>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connectState<Omit<Props, 'settings'>>(Settings, state => {
|
export default connectState<Omit<Props, "settings">>(
|
||||||
return {
|
SettingsProvider,
|
||||||
settings: state.settings
|
(state) => {
|
||||||
}
|
return {
|
||||||
});
|
settings: state.settings,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
|
@ -1,335 +1,360 @@
|
||||||
import { isTouchscreenDevice } from "../lib/isTouchscreenDevice";
|
|
||||||
import { createGlobalStyle } from "styled-components";
|
|
||||||
import { connectState } from "../redux/connector";
|
|
||||||
import { Children } from "../types/Preact";
|
|
||||||
import { useEffect } from "preact/hooks";
|
|
||||||
import { createContext } from "preact";
|
|
||||||
import { Helmet } from "react-helmet";
|
import { Helmet } from "react-helmet";
|
||||||
|
import { createGlobalStyle } from "styled-components";
|
||||||
|
|
||||||
|
import { createContext } from "preact";
|
||||||
|
import { useEffect } from "preact/hooks";
|
||||||
|
|
||||||
|
import { isTouchscreenDevice } from "../lib/isTouchscreenDevice";
|
||||||
|
|
||||||
|
import { connectState } from "../redux/connector";
|
||||||
|
|
||||||
|
import { Children } from "../types/Preact";
|
||||||
|
|
||||||
export type Variables =
|
export type Variables =
|
||||||
| "accent"
|
| "accent"
|
||||||
| "background"
|
| "background"
|
||||||
| "foreground"
|
| "foreground"
|
||||||
| "block"
|
| "block"
|
||||||
| "message-box"
|
| "message-box"
|
||||||
| "mention"
|
| "mention"
|
||||||
| "success"
|
| "success"
|
||||||
| "warning"
|
| "warning"
|
||||||
| "error"
|
| "error"
|
||||||
| "hover"
|
| "hover"
|
||||||
| "scrollbar-thumb"
|
| "scrollbar-thumb"
|
||||||
| "scrollbar-track"
|
| "scrollbar-track"
|
||||||
| "primary-background"
|
| "primary-background"
|
||||||
| "primary-header"
|
| "primary-header"
|
||||||
| "secondary-background"
|
| "secondary-background"
|
||||||
| "secondary-foreground"
|
| "secondary-foreground"
|
||||||
| "secondary-header"
|
| "secondary-header"
|
||||||
| "tertiary-background"
|
| "tertiary-background"
|
||||||
| "tertiary-foreground"
|
| "tertiary-foreground"
|
||||||
| "status-online"
|
| "status-online"
|
||||||
| "status-away"
|
| "status-away"
|
||||||
| "status-busy"
|
| "status-busy"
|
||||||
| "status-streaming"
|
| "status-streaming"
|
||||||
| "status-invisible"
|
| "status-invisible";
|
||||||
|
|
||||||
// While this isn't used, it'd be good to keep this up to date as a reference or for future use
|
// 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 =
|
export type HiddenVariables =
|
||||||
| "font"
|
| "font"
|
||||||
| "ligatures"
|
| "ligatures"
|
||||||
| "app-height"
|
| "app-height"
|
||||||
| "sidebar-active"
|
| "sidebar-active"
|
||||||
| "monospace-font"
|
| "monospace-font";
|
||||||
|
|
||||||
export type Fonts = 'Open Sans' | 'Inter' | 'Atkinson Hyperlegible' | 'Roboto' | 'Noto Sans' | 'Lato' | 'Bree Serif' | 'Montserrat' | 'Poppins' | 'Raleway' | 'Ubuntu' | 'Comic Neue';
|
export type Fonts =
|
||||||
export type MonoscapeFonts = 'Fira Code' | 'Roboto Mono' | 'Source Code Pro' | 'Space Mono' | 'Ubuntu Mono';
|
| "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";
|
||||||
|
|
||||||
export type Theme = {
|
export type Theme = {
|
||||||
[variable in Variables]: string;
|
[variable in Variables]: string;
|
||||||
} & {
|
} & {
|
||||||
light?: boolean;
|
light?: boolean;
|
||||||
font?: Fonts;
|
font?: Fonts;
|
||||||
css?: string;
|
css?: string;
|
||||||
monoscapeFont?: MonoscapeFonts;
|
monoscapeFont?: MonoscapeFonts;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface ThemeOptions {
|
export interface ThemeOptions {
|
||||||
preset?: string;
|
preset?: string;
|
||||||
ligatures?: boolean;
|
ligatures?: boolean;
|
||||||
custom?: Partial<Theme>;
|
custom?: Partial<Theme>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// import aaa from "@fontsource/open-sans/300.css?raw";
|
// import aaa from "@fontsource/open-sans/300.css?raw";
|
||||||
// console.info(aaa);
|
// console.info(aaa);
|
||||||
|
|
||||||
export const FONTS: Record<Fonts, { name: string, load: () => void }> = {
|
export const FONTS: Record<Fonts, { name: string; load: () => void }> = {
|
||||||
"Open Sans": {
|
"Open Sans": {
|
||||||
name: "Open Sans",
|
name: "Open Sans",
|
||||||
load: async () => {
|
load: async () => {
|
||||||
await import("@fontsource/open-sans/300.css");
|
await import("@fontsource/open-sans/300.css");
|
||||||
await import("@fontsource/open-sans/400.css");
|
await import("@fontsource/open-sans/400.css");
|
||||||
await import("@fontsource/open-sans/600.css");
|
await import("@fontsource/open-sans/600.css");
|
||||||
await import("@fontsource/open-sans/700.css");
|
await import("@fontsource/open-sans/700.css");
|
||||||
await import("@fontsource/open-sans/400-italic.css");
|
await import("@fontsource/open-sans/400-italic.css");
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
Inter: {
|
Inter: {
|
||||||
name: "Inter",
|
name: "Inter",
|
||||||
load: async () => {
|
load: async () => {
|
||||||
await import("@fontsource/inter/300.css");
|
await import("@fontsource/inter/300.css");
|
||||||
await import("@fontsource/inter/400.css");
|
await import("@fontsource/inter/400.css");
|
||||||
await import("@fontsource/inter/600.css");
|
await import("@fontsource/inter/600.css");
|
||||||
await import("@fontsource/inter/700.css");
|
await import("@fontsource/inter/700.css");
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
"Atkinson Hyperlegible": {
|
"Atkinson Hyperlegible": {
|
||||||
name: "Atkinson Hyperlegible",
|
name: "Atkinson Hyperlegible",
|
||||||
load: async () => {
|
load: async () => {
|
||||||
await import("@fontsource/atkinson-hyperlegible/400.css");
|
await import("@fontsource/atkinson-hyperlegible/400.css");
|
||||||
await import("@fontsource/atkinson-hyperlegible/700.css");
|
await import("@fontsource/atkinson-hyperlegible/700.css");
|
||||||
await import("@fontsource/atkinson-hyperlegible/400-italic.css");
|
await import("@fontsource/atkinson-hyperlegible/400-italic.css");
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
"Roboto": {
|
Roboto: {
|
||||||
name: "Roboto",
|
name: "Roboto",
|
||||||
load: async () => {
|
load: async () => {
|
||||||
await import("@fontsource/roboto/400.css");
|
await import("@fontsource/roboto/400.css");
|
||||||
await import("@fontsource/roboto/700.css");
|
await import("@fontsource/roboto/700.css");
|
||||||
await import("@fontsource/roboto/400-italic.css");
|
await import("@fontsource/roboto/400-italic.css");
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
"Noto Sans": {
|
"Noto Sans": {
|
||||||
name: "Noto Sans",
|
name: "Noto Sans",
|
||||||
load: async () => {
|
load: async () => {
|
||||||
await import("@fontsource/noto-sans/400.css");
|
await import("@fontsource/noto-sans/400.css");
|
||||||
await import("@fontsource/noto-sans/700.css");
|
await import("@fontsource/noto-sans/700.css");
|
||||||
await import("@fontsource/noto-sans/400-italic.css");
|
await import("@fontsource/noto-sans/400-italic.css");
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
"Bree Serif": {
|
"Bree Serif": {
|
||||||
name: "Bree Serif",
|
name: "Bree Serif",
|
||||||
load: () => import("@fontsource/bree-serif/400.css")
|
load: () => import("@fontsource/bree-serif/400.css"),
|
||||||
},
|
},
|
||||||
"Lato": {
|
Lato: {
|
||||||
name: "Lato",
|
name: "Lato",
|
||||||
load: async () => {
|
load: async () => {
|
||||||
await import("@fontsource/lato/300.css");
|
await import("@fontsource/lato/300.css");
|
||||||
await import("@fontsource/lato/400.css");
|
await import("@fontsource/lato/400.css");
|
||||||
await import("@fontsource/lato/700.css");
|
await import("@fontsource/lato/700.css");
|
||||||
await import("@fontsource/lato/400-italic.css");
|
await import("@fontsource/lato/400-italic.css");
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
"Montserrat": {
|
Montserrat: {
|
||||||
name: "Montserrat",
|
name: "Montserrat",
|
||||||
load: async () => {
|
load: async () => {
|
||||||
await import("@fontsource/montserrat/300.css");
|
await import("@fontsource/montserrat/300.css");
|
||||||
await import("@fontsource/montserrat/400.css");
|
await import("@fontsource/montserrat/400.css");
|
||||||
await import("@fontsource/montserrat/600.css");
|
await import("@fontsource/montserrat/600.css");
|
||||||
await import("@fontsource/montserrat/700.css");
|
await import("@fontsource/montserrat/700.css");
|
||||||
await import("@fontsource/montserrat/400-italic.css");
|
await import("@fontsource/montserrat/400-italic.css");
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
"Poppins": {
|
Poppins: {
|
||||||
name: "Poppins",
|
name: "Poppins",
|
||||||
load: async () => {
|
load: async () => {
|
||||||
await import("@fontsource/poppins/300.css");
|
await import("@fontsource/poppins/300.css");
|
||||||
await import("@fontsource/poppins/400.css");
|
await import("@fontsource/poppins/400.css");
|
||||||
await import("@fontsource/poppins/600.css");
|
await import("@fontsource/poppins/600.css");
|
||||||
await import("@fontsource/poppins/700.css");
|
await import("@fontsource/poppins/700.css");
|
||||||
await import("@fontsource/poppins/400-italic.css");
|
await import("@fontsource/poppins/400-italic.css");
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
"Raleway": {
|
Raleway: {
|
||||||
name: "Raleway",
|
name: "Raleway",
|
||||||
load: async () => {
|
load: async () => {
|
||||||
await import("@fontsource/raleway/300.css");
|
await import("@fontsource/raleway/300.css");
|
||||||
await import("@fontsource/raleway/400.css");
|
await import("@fontsource/raleway/400.css");
|
||||||
await import("@fontsource/raleway/600.css");
|
await import("@fontsource/raleway/600.css");
|
||||||
await import("@fontsource/raleway/700.css");
|
await import("@fontsource/raleway/700.css");
|
||||||
await import("@fontsource/raleway/400-italic.css");
|
await import("@fontsource/raleway/400-italic.css");
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
"Ubuntu": {
|
Ubuntu: {
|
||||||
name: "Ubuntu",
|
name: "Ubuntu",
|
||||||
load: async () => {
|
load: async () => {
|
||||||
await import("@fontsource/ubuntu/300.css");
|
await import("@fontsource/ubuntu/300.css");
|
||||||
await import("@fontsource/ubuntu/400.css");
|
await import("@fontsource/ubuntu/400.css");
|
||||||
await import("@fontsource/ubuntu/500.css");
|
await import("@fontsource/ubuntu/500.css");
|
||||||
await import("@fontsource/ubuntu/700.css");
|
await import("@fontsource/ubuntu/700.css");
|
||||||
await import("@fontsource/ubuntu/400-italic.css");
|
await import("@fontsource/ubuntu/400-italic.css");
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
"Comic Neue": {
|
"Comic Neue": {
|
||||||
name: "Comic Neue",
|
name: "Comic Neue",
|
||||||
load: async () => {
|
load: async () => {
|
||||||
await import("@fontsource/comic-neue/300.css");
|
await import("@fontsource/comic-neue/300.css");
|
||||||
await import("@fontsource/comic-neue/400.css");
|
await import("@fontsource/comic-neue/400.css");
|
||||||
await import("@fontsource/comic-neue/700.css");
|
await import("@fontsource/comic-neue/700.css");
|
||||||
await import("@fontsource/comic-neue/400-italic.css");
|
await import("@fontsource/comic-neue/400-italic.css");
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MONOSCAPE_FONTS: Record<MonoscapeFonts, { name: string, load: () => void }> = {
|
export const MONOSCAPE_FONTS: Record<
|
||||||
"Fira Code": {
|
MonoscapeFonts,
|
||||||
name: "Fira Code",
|
{ name: string; load: () => void }
|
||||||
load: () => import("@fontsource/fira-code/400.css")
|
> = {
|
||||||
},
|
"Fira Code": {
|
||||||
"Roboto Mono": {
|
name: "Fira Code",
|
||||||
name: "Roboto Mono",
|
load: () => import("@fontsource/fira-code/400.css"),
|
||||||
load: () => import("@fontsource/roboto-mono/400.css")
|
},
|
||||||
},
|
"Roboto Mono": {
|
||||||
"Source Code Pro": {
|
name: "Roboto Mono",
|
||||||
name: "Source Code Pro",
|
load: () => import("@fontsource/roboto-mono/400.css"),
|
||||||
load: () => import("@fontsource/source-code-pro/400.css")
|
},
|
||||||
},
|
"Source Code Pro": {
|
||||||
"Space Mono": {
|
name: "Source Code Pro",
|
||||||
name: "Space Mono",
|
load: () => import("@fontsource/source-code-pro/400.css"),
|
||||||
load: () => import("@fontsource/space-mono/400.css")
|
},
|
||||||
},
|
"Space Mono": {
|
||||||
"Ubuntu Mono": {
|
name: "Space Mono",
|
||||||
name: "Ubuntu Mono",
|
load: () => import("@fontsource/space-mono/400.css"),
|
||||||
load: () => import("@fontsource/ubuntu-mono/400.css")
|
},
|
||||||
}
|
"Ubuntu Mono": {
|
||||||
|
name: "Ubuntu Mono",
|
||||||
|
load: () => import("@fontsource/ubuntu-mono/400.css"),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FONT_KEYS = Object.keys(FONTS).sort();
|
export const FONT_KEYS = Object.keys(FONTS).sort();
|
||||||
export const MONOSCAPE_FONT_KEYS = Object.keys(MONOSCAPE_FONTS).sort();
|
export const MONOSCAPE_FONT_KEYS = Object.keys(MONOSCAPE_FONTS).sort();
|
||||||
|
|
||||||
export const DEFAULT_FONT = 'Open Sans';
|
export const DEFAULT_FONT = "Open Sans";
|
||||||
export const DEFAULT_MONO_FONT = 'Fira Code';
|
export const DEFAULT_MONO_FONT = "Fira Code";
|
||||||
|
|
||||||
// Generated from https://gitlab.insrt.uk/revolt/community/themes
|
// Generated from https://gitlab.insrt.uk/revolt/community/themes
|
||||||
export const PRESETS: Record<string, Theme> = {
|
export const PRESETS: Record<string, Theme> = {
|
||||||
light: {
|
light: {
|
||||||
light: true,
|
light: true,
|
||||||
accent: "#FD6671",
|
accent: "#FD6671",
|
||||||
background: "#F6F6F6",
|
background: "#F6F6F6",
|
||||||
foreground: "#101010",
|
foreground: "#101010",
|
||||||
block: "#414141",
|
block: "#414141",
|
||||||
"message-box": "#F1F1F1",
|
"message-box": "#F1F1F1",
|
||||||
mention: "rgba(251, 255, 0, 0.40)",
|
mention: "rgba(251, 255, 0, 0.40)",
|
||||||
success: "#65E572",
|
success: "#65E572",
|
||||||
warning: "#FAA352",
|
warning: "#FAA352",
|
||||||
error: "#F06464",
|
error: "#F06464",
|
||||||
hover: "rgba(0, 0, 0, 0.2)",
|
hover: "rgba(0, 0, 0, 0.2)",
|
||||||
"scrollbar-thumb": "#CA525A",
|
"scrollbar-thumb": "#CA525A",
|
||||||
"scrollbar-track": "transparent",
|
"scrollbar-track": "transparent",
|
||||||
"primary-background": "#FFFFFF",
|
"primary-background": "#FFFFFF",
|
||||||
"primary-header": "#F1F1F1",
|
"primary-header": "#F1F1F1",
|
||||||
"secondary-background": "#F1F1F1",
|
"secondary-background": "#F1F1F1",
|
||||||
"secondary-foreground": "#888888",
|
"secondary-foreground": "#888888",
|
||||||
"secondary-header": "#F1F1F1",
|
"secondary-header": "#F1F1F1",
|
||||||
"tertiary-background": "#4D4D4D",
|
"tertiary-background": "#4D4D4D",
|
||||||
"tertiary-foreground": "#646464",
|
"tertiary-foreground": "#646464",
|
||||||
"status-online": "#3ABF7E",
|
"status-online": "#3ABF7E",
|
||||||
"status-away": "#F39F00",
|
"status-away": "#F39F00",
|
||||||
"status-busy": "#F84848",
|
"status-busy": "#F84848",
|
||||||
"status-streaming": "#977EFF",
|
"status-streaming": "#977EFF",
|
||||||
"status-invisible": "#A5A5A5"
|
"status-invisible": "#A5A5A5",
|
||||||
},
|
},
|
||||||
dark: {
|
dark: {
|
||||||
light: false,
|
light: false,
|
||||||
accent: "#FD6671",
|
accent: "#FD6671",
|
||||||
background: "#191919",
|
background: "#191919",
|
||||||
foreground: "#F6F6F6",
|
foreground: "#F6F6F6",
|
||||||
block: "#2D2D2D",
|
block: "#2D2D2D",
|
||||||
"message-box": "#363636",
|
"message-box": "#363636",
|
||||||
mention: "rgba(251, 255, 0, 0.06)",
|
mention: "rgba(251, 255, 0, 0.06)",
|
||||||
success: "#65E572",
|
success: "#65E572",
|
||||||
warning: "#FAA352",
|
warning: "#FAA352",
|
||||||
error: "#F06464",
|
error: "#F06464",
|
||||||
hover: "rgba(0, 0, 0, 0.1)",
|
hover: "rgba(0, 0, 0, 0.1)",
|
||||||
"scrollbar-thumb": "#CA525A",
|
"scrollbar-thumb": "#CA525A",
|
||||||
"scrollbar-track": "transparent",
|
"scrollbar-track": "transparent",
|
||||||
"primary-background": "#242424",
|
"primary-background": "#242424",
|
||||||
"primary-header": "#363636",
|
"primary-header": "#363636",
|
||||||
"secondary-background": "#1E1E1E",
|
"secondary-background": "#1E1E1E",
|
||||||
"secondary-foreground": "#C8C8C8",
|
"secondary-foreground": "#C8C8C8",
|
||||||
"secondary-header": "#2D2D2D",
|
"secondary-header": "#2D2D2D",
|
||||||
"tertiary-background": "#4D4D4D",
|
"tertiary-background": "#4D4D4D",
|
||||||
"tertiary-foreground": "#848484",
|
"tertiary-foreground": "#848484",
|
||||||
"status-online": "#3ABF7E",
|
"status-online": "#3ABF7E",
|
||||||
"status-away": "#F39F00",
|
"status-away": "#F39F00",
|
||||||
"status-busy": "#F84848",
|
"status-busy": "#F84848",
|
||||||
"status-streaming": "#977EFF",
|
"status-streaming": "#977EFF",
|
||||||
"status-invisible": "#A5A5A5"
|
"status-invisible": "#A5A5A5",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const keys = Object.keys(PRESETS.dark);
|
const keys = Object.keys(PRESETS.dark);
|
||||||
const GlobalTheme = createGlobalStyle<{ theme: Theme }>`
|
const GlobalTheme = createGlobalStyle<{ theme: Theme }>`
|
||||||
:root {
|
:root {
|
||||||
${(props) =>
|
${(props) =>
|
||||||
(Object.keys(props.theme) as Variables[]).map((key) => {
|
(Object.keys(props.theme) as Variables[]).map((key) => {
|
||||||
if (!keys.includes(key)) return;
|
if (!keys.includes(key)) return;
|
||||||
return `--${key}: ${props.theme[key]};`;
|
return `--${key}: ${props.theme[key]};`;
|
||||||
})}
|
})}
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
// Load the default default them and apply extras later
|
// Load the default default them and apply extras later
|
||||||
export const ThemeContext = createContext<Theme>(PRESETS['dark']);
|
export const ThemeContext = createContext<Theme>(PRESETS["dark"]);
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: Children;
|
children: Children;
|
||||||
options?: ThemeOptions;
|
options?: ThemeOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
function Theme({ children, options }: Props) {
|
function Theme({ children, options }: Props) {
|
||||||
const theme: Theme = {
|
const theme: Theme = {
|
||||||
...PRESETS["dark"],
|
...PRESETS["dark"],
|
||||||
...PRESETS[options?.preset ?? ''],
|
...PRESETS[options?.preset ?? ""],
|
||||||
...options?.custom
|
...options?.custom,
|
||||||
};
|
};
|
||||||
|
|
||||||
const root = document.documentElement.style;
|
const root = document.documentElement.style;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const font = theme.font ?? DEFAULT_FONT;
|
const font = theme.font ?? DEFAULT_FONT;
|
||||||
root.setProperty('--font', `"${font}"`);
|
root.setProperty("--font", `"${font}"`);
|
||||||
FONTS[font].load();
|
FONTS[font].load();
|
||||||
}, [ theme.font ]);
|
}, [theme.font]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const font = theme.monoscapeFont ?? DEFAULT_MONO_FONT;
|
const font = theme.monoscapeFont ?? DEFAULT_MONO_FONT;
|
||||||
root.setProperty('--monoscape-font', `"${font}"`);
|
root.setProperty("--monoscape-font", `"${font}"`);
|
||||||
MONOSCAPE_FONTS[font].load();
|
MONOSCAPE_FONTS[font].load();
|
||||||
}, [ theme.monoscapeFont ]);
|
}, [theme.monoscapeFont]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
root.setProperty('--ligatures', options?.ligatures ? 'normal' : 'none');
|
root.setProperty("--ligatures", options?.ligatures ? "normal" : "none");
|
||||||
}, [ options?.ligatures ]);
|
}, [options?.ligatures]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const resize = () => root.setProperty('--app-height', `${window.innerHeight}px`);
|
const resize = () =>
|
||||||
resize();
|
root.setProperty("--app-height", `${window.innerHeight}px`);
|
||||||
|
resize();
|
||||||
|
|
||||||
window.addEventListener('resize', resize);
|
window.addEventListener("resize", resize);
|
||||||
return () => window.removeEventListener('resize', resize);
|
return () => window.removeEventListener("resize", resize);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ThemeContext.Provider value={theme}>
|
<ThemeContext.Provider value={theme}>
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<meta
|
<meta
|
||||||
name="theme-color"
|
name="theme-color"
|
||||||
content={
|
content={
|
||||||
isTouchscreenDevice
|
isTouchscreenDevice
|
||||||
? theme["primary-header"]
|
? theme["primary-header"]
|
||||||
: theme["background"]
|
: theme["background"]
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<GlobalTheme theme={theme} />
|
<GlobalTheme theme={theme} />
|
||||||
{theme.css && (
|
{theme.css && (
|
||||||
<style dangerouslySetInnerHTML={{ __html: theme.css }} />
|
<style dangerouslySetInnerHTML={{ __html: theme.css }} />
|
||||||
)}
|
)}
|
||||||
{ children }
|
{children}
|
||||||
</ThemeContext.Provider>
|
</ThemeContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connectState<{ children: Children }>(Theme, state => {
|
export default connectState<{ children: Children }>(Theme, (state) => {
|
||||||
return {
|
return {
|
||||||
options: state.settings.theme
|
options: state.settings.theme,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,36 +1,38 @@
|
||||||
import { createContext } from "preact";
|
import { createContext } from "preact";
|
||||||
import { Children } from "../types/Preact";
|
|
||||||
import { useForceUpdate } from "./revoltjs/hooks";
|
|
||||||
import { AppContext } from "./revoltjs/RevoltClient";
|
|
||||||
import type VoiceClient from "../lib/vortex/VoiceClient";
|
|
||||||
import type { ProduceType, VoiceUser } from "../lib/vortex/Types";
|
|
||||||
import { useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
import { useContext, useEffect, useMemo, useRef, useState } from "preact/hooks";
|
||||||
|
|
||||||
|
import type { ProduceType, VoiceUser } from "../lib/vortex/Types";
|
||||||
|
import type VoiceClient from "../lib/vortex/VoiceClient";
|
||||||
|
|
||||||
|
import { Children } from "../types/Preact";
|
||||||
import { SoundContext } from "./Settings";
|
import { SoundContext } from "./Settings";
|
||||||
|
import { AppContext } from "./revoltjs/RevoltClient";
|
||||||
|
import { useForceUpdate } from "./revoltjs/hooks";
|
||||||
|
|
||||||
export enum VoiceStatus {
|
export enum VoiceStatus {
|
||||||
LOADING = 0,
|
LOADING = 0,
|
||||||
UNAVAILABLE,
|
UNAVAILABLE,
|
||||||
ERRORED,
|
ERRORED,
|
||||||
READY = 3,
|
READY = 3,
|
||||||
CONNECTING = 4,
|
CONNECTING = 4,
|
||||||
AUTHENTICATING,
|
AUTHENTICATING,
|
||||||
RTC_CONNECTING,
|
RTC_CONNECTING,
|
||||||
CONNECTED
|
CONNECTED,
|
||||||
// RECONNECTING
|
// RECONNECTING
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VoiceOperations {
|
export interface VoiceOperations {
|
||||||
connect: (channelId: string) => Promise<void>;
|
connect: (channelId: string) => Promise<void>;
|
||||||
disconnect: () => void;
|
disconnect: () => void;
|
||||||
isProducing: (type: ProduceType) => boolean;
|
isProducing: (type: ProduceType) => boolean;
|
||||||
startProducing: (type: ProduceType) => Promise<void>;
|
startProducing: (type: ProduceType) => Promise<void>;
|
||||||
stopProducing: (type: ProduceType) => Promise<void> | undefined;
|
stopProducing: (type: ProduceType) => Promise<void> | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface VoiceState {
|
export interface VoiceState {
|
||||||
roomId?: string;
|
roomId?: string;
|
||||||
status: VoiceStatus;
|
status: VoiceStatus;
|
||||||
participants?: Readonly<Map<string, VoiceUser>>;
|
participants?: Readonly<Map<string, VoiceUser>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// They should be present from first render. - insert's words
|
// They should be present from first render. - insert's words
|
||||||
|
@ -38,166 +40,168 @@ export const VoiceContext = createContext<VoiceState>(null!);
|
||||||
export const VoiceOperationsContext = createContext<VoiceOperations>(null!);
|
export const VoiceOperationsContext = createContext<VoiceOperations>(null!);
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: Children;
|
children: Children;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Voice({ children }: Props) {
|
export default function Voice({ children }: Props) {
|
||||||
const revoltClient = useContext(AppContext);
|
const revoltClient = useContext(AppContext);
|
||||||
const [client, setClient] = useState<VoiceClient | undefined>(undefined);
|
const [client, setClient] = useState<VoiceClient | undefined>(undefined);
|
||||||
const [state, setState] = useState<VoiceState>({
|
const [state, setState] = useState<VoiceState>({
|
||||||
status: VoiceStatus.LOADING,
|
status: VoiceStatus.LOADING,
|
||||||
participants: new Map()
|
participants: new Map(),
|
||||||
});
|
});
|
||||||
|
|
||||||
function setStatus(status: VoiceStatus, roomId?: string) {
|
function setStatus(status: VoiceStatus, roomId?: string) {
|
||||||
setState({
|
setState({
|
||||||
status,
|
status,
|
||||||
roomId: roomId ?? client?.roomId,
|
roomId: roomId ?? client?.roomId,
|
||||||
participants: client?.participants ?? new Map(),
|
participants: client?.participants ?? new Map(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
import('../lib/vortex/VoiceClient')
|
import("../lib/vortex/VoiceClient")
|
||||||
.then(({ default: VoiceClient }) => {
|
.then(({ default: VoiceClient }) => {
|
||||||
const client = new VoiceClient();
|
const client = new VoiceClient();
|
||||||
setClient(client);
|
setClient(client);
|
||||||
|
|
||||||
if (!client?.supported()) {
|
if (!client?.supported()) {
|
||||||
setStatus(VoiceStatus.UNAVAILABLE);
|
setStatus(VoiceStatus.UNAVAILABLE);
|
||||||
} else {
|
} else {
|
||||||
setStatus(VoiceStatus.READY);
|
setStatus(VoiceStatus.READY);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(err => {
|
.catch((err) => {
|
||||||
console.error('Failed to load voice library!', err);
|
console.error("Failed to load voice library!", err);
|
||||||
setStatus(VoiceStatus.UNAVAILABLE);
|
setStatus(VoiceStatus.UNAVAILABLE);
|
||||||
})
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isConnecting = useRef(false);
|
const isConnecting = useRef(false);
|
||||||
const operations: VoiceOperations = useMemo(() => {
|
const operations: VoiceOperations = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
connect: async channelId => {
|
connect: async (channelId) => {
|
||||||
if (!client?.supported())
|
if (!client?.supported()) throw new Error("RTC is unavailable");
|
||||||
throw new Error("RTC is unavailable");
|
|
||||||
|
|
||||||
isConnecting.current = true;
|
|
||||||
setStatus(VoiceStatus.CONNECTING, channelId);
|
|
||||||
|
|
||||||
try {
|
isConnecting.current = true;
|
||||||
const call = await revoltClient.channels.joinCall(
|
setStatus(VoiceStatus.CONNECTING, channelId);
|
||||||
channelId
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isConnecting.current) {
|
try {
|
||||||
setStatus(VoiceStatus.READY);
|
const call = await revoltClient.channels.joinCall(
|
||||||
return;
|
channelId,
|
||||||
}
|
);
|
||||||
|
|
||||||
// ! FIXME: use configuration to check if voso is enabled
|
if (!isConnecting.current) {
|
||||||
// await client.connect("wss://voso.revolt.chat/ws");
|
setStatus(VoiceStatus.READY);
|
||||||
await client.connect("wss://voso.revolt.chat/ws", channelId);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setStatus(VoiceStatus.AUTHENTICATING);
|
// ! 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,
|
||||||
|
);
|
||||||
|
|
||||||
await client.authenticate(call.token);
|
setStatus(VoiceStatus.AUTHENTICATING);
|
||||||
setStatus(VoiceStatus.RTC_CONNECTING);
|
|
||||||
|
|
||||||
await client.initializeTransports();
|
await client.authenticate(call.token);
|
||||||
} catch (error) {
|
setStatus(VoiceStatus.RTC_CONNECTING);
|
||||||
console.error(error);
|
|
||||||
setStatus(VoiceStatus.READY);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatus(VoiceStatus.CONNECTED);
|
await client.initializeTransports();
|
||||||
isConnecting.current = false;
|
} catch (error) {
|
||||||
},
|
console.error(error);
|
||||||
disconnect: () => {
|
setStatus(VoiceStatus.READY);
|
||||||
if (!client?.supported())
|
return;
|
||||||
throw new Error("RTC is unavailable");
|
}
|
||||||
|
|
||||||
// if (status <= VoiceStatus.READY) return;
|
|
||||||
// this will not update in this context
|
|
||||||
|
|
||||||
isConnecting.current = false;
|
setStatus(VoiceStatus.CONNECTED);
|
||||||
client.disconnect();
|
isConnecting.current = false;
|
||||||
setStatus(VoiceStatus.READY);
|
},
|
||||||
},
|
disconnect: () => {
|
||||||
isProducing: (type: ProduceType) => {
|
if (!client?.supported()) throw new Error("RTC is unavailable");
|
||||||
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(
|
// if (status <= VoiceStatus.READY) return;
|
||||||
mediaStream.getAudioTracks()[0],
|
// this will not update in this context
|
||||||
"audio"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
stopProducing: (type: ProduceType) => {
|
|
||||||
return client?.stopProduce(type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [ client ]);
|
|
||||||
|
|
||||||
const { forceUpdate } = useForceUpdate();
|
isConnecting.current = false;
|
||||||
const playSound = useContext(SoundContext);
|
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,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
await client?.startProduce(
|
||||||
if (!client?.supported()) return;
|
mediaStream.getAudioTracks()[0],
|
||||||
|
"audio",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
stopProducing: (type: ProduceType) => {
|
||||||
|
return client?.stopProduce(type);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}, [client]);
|
||||||
|
|
||||||
// ! FIXME: message for fatal:
|
const { forceUpdate } = useForceUpdate();
|
||||||
// ! get rid of these force updates
|
const playSound = useContext(SoundContext);
|
||||||
// ! handle it through state or smth
|
|
||||||
|
|
||||||
client.on("startProduce", forceUpdate);
|
useEffect(() => {
|
||||||
client.on("stopProduce", forceUpdate);
|
if (!client?.supported()) return;
|
||||||
|
|
||||||
client.on("userJoined", () => {
|
// ! FIXME: message for fatal:
|
||||||
playSound('call_join');
|
// ! get rid of these force updates
|
||||||
forceUpdate();
|
// ! handle it through state or smth
|
||||||
});
|
|
||||||
client.on("userLeft", () => {
|
|
||||||
playSound('call_leave');
|
|
||||||
forceUpdate();
|
|
||||||
});
|
|
||||||
client.on("userStartProduce", forceUpdate);
|
|
||||||
client.on("userStopProduce", forceUpdate);
|
|
||||||
client.on("close", forceUpdate);
|
|
||||||
|
|
||||||
return () => {
|
client.on("startProduce", forceUpdate);
|
||||||
client.removeListener("startProduce", forceUpdate);
|
client.on("stopProduce", forceUpdate);
|
||||||
client.removeListener("stopProduce", forceUpdate);
|
|
||||||
|
|
||||||
client.removeListener("userJoined", forceUpdate);
|
client.on("userJoined", () => {
|
||||||
client.removeListener("userLeft", forceUpdate);
|
playSound("call_join");
|
||||||
client.removeListener("userStartProduce", forceUpdate);
|
forceUpdate();
|
||||||
client.removeListener("userStopProduce", forceUpdate);
|
});
|
||||||
client.removeListener("close", forceUpdate);
|
client.on("userLeft", () => {
|
||||||
};
|
playSound("call_leave");
|
||||||
}, [ client, state ]);
|
forceUpdate();
|
||||||
|
});
|
||||||
|
client.on("userStartProduce", forceUpdate);
|
||||||
|
client.on("userStopProduce", forceUpdate);
|
||||||
|
client.on("close", forceUpdate);
|
||||||
|
|
||||||
return (
|
return () => {
|
||||||
<VoiceContext.Provider value={state}>
|
client.removeListener("startProduce", forceUpdate);
|
||||||
<VoiceOperationsContext.Provider value={operations}>
|
client.removeListener("stopProduce", forceUpdate);
|
||||||
{ children }
|
|
||||||
</VoiceOperationsContext.Provider>
|
client.removeListener("userJoined", forceUpdate);
|
||||||
</VoiceContext.Provider>
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,32 +1,31 @@
|
||||||
import State from "../redux/State";
|
|
||||||
import { Children } from "../types/Preact";
|
|
||||||
import { BrowserRouter as Router } from "react-router-dom";
|
import { BrowserRouter as Router } from "react-router-dom";
|
||||||
|
|
||||||
import Intermediate from './intermediate/Intermediate';
|
import State from "../redux/State";
|
||||||
import Client from './revoltjs/RevoltClient';
|
|
||||||
import Settings from "./Settings";
|
import { Children } from "../types/Preact";
|
||||||
import Locale from "./Locale";
|
import Locale from "./Locale";
|
||||||
import Voice from "./Voice";
|
import Settings from "./Settings";
|
||||||
import Theme from "./Theme";
|
import Theme from "./Theme";
|
||||||
|
import Voice from "./Voice";
|
||||||
|
import Intermediate from "./intermediate/Intermediate";
|
||||||
|
import Client from "./revoltjs/RevoltClient";
|
||||||
|
|
||||||
export default function Context({ children }: { children: Children }) {
|
export default function Context({ children }: { children: Children }) {
|
||||||
return (
|
return (
|
||||||
<Router>
|
<Router>
|
||||||
<State>
|
<State>
|
||||||
<Theme>
|
<Theme>
|
||||||
<Settings>
|
<Settings>
|
||||||
<Locale>
|
<Locale>
|
||||||
<Intermediate>
|
<Intermediate>
|
||||||
<Client>
|
<Client>
|
||||||
<Voice>
|
<Voice>{children}</Voice>
|
||||||
{children}
|
</Client>
|
||||||
</Voice>
|
</Intermediate>
|
||||||
</Client>
|
</Locale>
|
||||||
</Intermediate>
|
</Settings>
|
||||||
</Locale>
|
</Theme>
|
||||||
</Settings>
|
</State>
|
||||||
</Theme>
|
</Router>
|
||||||
</State>
|
);
|
||||||
</Router>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,141 +1,179 @@
|
||||||
import { Attachment, Channels, EmbedImage, Servers, Users } from "revolt.js/dist/api/objects";
|
|
||||||
import { useContext, useEffect, useMemo, useState } from "preact/hooks";
|
|
||||||
import { internalSubscribe } from "../../lib/eventEmitter";
|
|
||||||
import { Action } from "../../components/ui/Modal";
|
|
||||||
import { useHistory } from "react-router-dom";
|
|
||||||
import { Children } from "../../types/Preact";
|
|
||||||
import { createContext } from "preact";
|
|
||||||
import { Prompt } from "react-router";
|
import { Prompt } from "react-router";
|
||||||
import Modals from './Modals';
|
import { useHistory } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Attachment,
|
||||||
|
Channels,
|
||||||
|
EmbedImage,
|
||||||
|
Servers,
|
||||||
|
Users,
|
||||||
|
} from "revolt.js/dist/api/objects";
|
||||||
|
|
||||||
|
import { createContext } from "preact";
|
||||||
|
import { useContext, useEffect, useMemo, useState } from "preact/hooks";
|
||||||
|
|
||||||
|
import { internalSubscribe } from "../../lib/eventEmitter";
|
||||||
|
|
||||||
|
import { Action } from "../../components/ui/Modal";
|
||||||
|
|
||||||
|
import { Children } from "../../types/Preact";
|
||||||
|
import Modals from "./Modals";
|
||||||
|
|
||||||
export type Screen =
|
export type Screen =
|
||||||
| { id: "none" }
|
| { id: "none" }
|
||||||
|
|
||||||
// Modals
|
// Modals
|
||||||
| { id: "signed_out" }
|
| { id: "signed_out" }
|
||||||
| { id: "error"; error: string }
|
| { id: "error"; error: string }
|
||||||
| { id: "clipboard"; text: string }
|
| { id: "clipboard"; text: string }
|
||||||
| { id: "_prompt"; question: Children; content?: Children; actions: Action[] }
|
| {
|
||||||
| ({ id: "special_prompt" } & (
|
id: "_prompt";
|
||||||
{ type: "leave_group", target: Channels.GroupChannel } |
|
question: Children;
|
||||||
{ type: "close_dm", target: Channels.DirectMessageChannel } |
|
content?: Children;
|
||||||
{ type: "leave_server", target: Servers.Server } |
|
actions: Action[];
|
||||||
{ type: "delete_server", target: Servers.Server } |
|
}
|
||||||
{ type: "delete_channel", target: Channels.TextChannel } |
|
| ({ id: "special_prompt" } & (
|
||||||
{ type: "delete_message", target: Channels.Message } |
|
| { type: "leave_group"; target: Channels.GroupChannel }
|
||||||
{ type: "create_invite", target: Channels.TextChannel | Channels.GroupChannel } |
|
| { type: "close_dm"; target: Channels.DirectMessageChannel }
|
||||||
{ type: "kick_member", target: Servers.Server, user: string } |
|
| { type: "leave_server"; target: Servers.Server }
|
||||||
{ type: "ban_member", target: Servers.Server, user: string } |
|
| { type: "delete_server"; target: Servers.Server }
|
||||||
{ type: "unfriend_user", target: Users.User } |
|
| { type: "delete_channel"; target: Channels.TextChannel }
|
||||||
{ type: "block_user", target: Users.User } |
|
| { type: "delete_message"; target: Channels.Message }
|
||||||
{ type: "create_channel", target: Servers.Server }
|
| {
|
||||||
)) |
|
type: "create_invite";
|
||||||
({ id: "special_input" } & (
|
target: Channels.TextChannel | Channels.GroupChannel;
|
||||||
{ type: "create_group" | "create_server" | "set_custom_status" | "add_friend" } |
|
}
|
||||||
{ type: "create_role", server: string, callback: (id: string) => void }
|
| { type: "kick_member"; target: Servers.Server; user: string }
|
||||||
))
|
| { type: "ban_member"; target: Servers.Server; user: string }
|
||||||
| {
|
| { type: "unfriend_user"; target: Users.User }
|
||||||
id: "_input";
|
| { type: "block_user"; target: Users.User }
|
||||||
question: Children;
|
| { type: "create_channel"; target: Servers.Server }
|
||||||
field: Children;
|
))
|
||||||
defaultValue?: string;
|
| ({ id: "special_input" } & (
|
||||||
callback: (value: string) => Promise<void>;
|
| {
|
||||||
}
|
type:
|
||||||
| {
|
| "create_group"
|
||||||
id: "onboarding";
|
| "create_server"
|
||||||
callback: (
|
| "set_custom_status"
|
||||||
username: string,
|
| "add_friend";
|
||||||
loginAfterSuccess?: true
|
}
|
||||||
) => Promise<void>;
|
| {
|
||||||
}
|
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
|
// Pop-overs
|
||||||
| { id: "image_viewer"; attachment?: Attachment; embed?: EmbedImage; }
|
| { id: "image_viewer"; attachment?: Attachment; embed?: EmbedImage }
|
||||||
| { id: "modify_account"; field: "username" | "email" | "password" }
|
| { id: "modify_account"; field: "username" | "email" | "password" }
|
||||||
| { id: "profile"; user_id: string }
|
| { id: "profile"; user_id: string }
|
||||||
| { id: "channel_info"; channel_id: string }
|
| { id: "channel_info"; channel_id: string }
|
||||||
| { id: "pending_requests"; users: string[] }
|
| { id: "pending_requests"; users: string[] }
|
||||||
| {
|
| {
|
||||||
id: "user_picker";
|
id: "user_picker";
|
||||||
omit?: string[];
|
omit?: string[];
|
||||||
callback: (users: string[]) => Promise<void>;
|
callback: (users: string[]) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const IntermediateContext = createContext({
|
export const IntermediateContext = createContext({
|
||||||
screen: { id: "none" } as Screen,
|
screen: { id: "none" } as Screen,
|
||||||
focusTaken: false
|
focusTaken: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const IntermediateActionsContext = createContext({
|
export const IntermediateActionsContext = createContext({
|
||||||
openScreen: (screen: Screen) => {},
|
openScreen: (screen: Screen) => {},
|
||||||
writeClipboard: (text: string) => {}
|
writeClipboard: (text: string) => {},
|
||||||
});
|
});
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: Children;
|
children: Children;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Intermediate(props: Props) {
|
export default function Intermediate(props: Props) {
|
||||||
const [screen, openScreen] = useState<Screen>({ id: "none" });
|
const [screen, openScreen] = useState<Screen>({ id: "none" });
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
|
|
||||||
const value = {
|
const value = {
|
||||||
screen,
|
screen,
|
||||||
focusTaken: screen.id !== 'none'
|
focusTaken: screen.id !== "none",
|
||||||
};
|
};
|
||||||
|
|
||||||
const actions = useMemo(() => {
|
const actions = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
openScreen: (screen: Screen) => openScreen(screen),
|
openScreen: (screen: Screen) => openScreen(screen),
|
||||||
writeClipboard: (text: string) => {
|
writeClipboard: (text: string) => {
|
||||||
if (navigator.clipboard) {
|
if (navigator.clipboard) {
|
||||||
navigator.clipboard.writeText(text);
|
navigator.clipboard.writeText(text);
|
||||||
} else {
|
} else {
|
||||||
actions.openScreen({ id: "clipboard", text });
|
actions.openScreen({ id: "clipboard", text });
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const openProfile = (user_id: string) => openScreen({ id: "profile", user_id });
|
const openProfile = (user_id: string) =>
|
||||||
const navigate = (path: string) => history.push(path);
|
openScreen({ id: "profile", user_id });
|
||||||
|
const navigate = (path: string) => history.push(path);
|
||||||
|
|
||||||
const subs = [
|
const subs = [
|
||||||
internalSubscribe("Intermediate", "openProfile", openProfile),
|
internalSubscribe("Intermediate", "openProfile", openProfile),
|
||||||
internalSubscribe("Intermediate", "navigate", navigate)
|
internalSubscribe("Intermediate", "navigate", navigate),
|
||||||
]
|
];
|
||||||
|
|
||||||
return () => subs.map(unsub => unsub());
|
return () => subs.map((unsub) => unsub());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<IntermediateContext.Provider value={value}>
|
<IntermediateContext.Provider value={value}>
|
||||||
<IntermediateActionsContext.Provider value={actions}>
|
<IntermediateActionsContext.Provider value={actions}>
|
||||||
{ screen.id !== 'onboarding' && props.children }
|
{screen.id !== "onboarding" && props.children}
|
||||||
<Modals
|
<Modals
|
||||||
{...value}
|
{...value}
|
||||||
{...actions}
|
{...actions}
|
||||||
key={
|
key={
|
||||||
screen.id
|
screen.id
|
||||||
} /** By specifying a key, we reset state whenever switching screen. */
|
} /** By specifying a key, we reset state whenever switching screen. */
|
||||||
/>
|
/>
|
||||||
<Prompt
|
<Prompt
|
||||||
when={[ 'modify_account', 'special_prompt', 'special_input', 'image_viewer', 'profile', 'channel_info', 'pending_requests', 'user_picker' ].includes(screen.id)}
|
when={[
|
||||||
message={(_, action) => {
|
"modify_account",
|
||||||
if (action === 'POP') {
|
"special_prompt",
|
||||||
openScreen({ id: 'none' });
|
"special_input",
|
||||||
setTimeout(() => history.push(history.location), 0);
|
"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;
|
return true;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</IntermediateActionsContext.Provider>
|
</IntermediateActionsContext.Provider>
|
||||||
</IntermediateContext.Provider>
|
</IntermediateContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useIntermediate = () => useContext(IntermediateActionsContext);
|
export const useIntermediate = () => useContext(IntermediateActionsContext);
|
||||||
|
|
|
@ -1,34 +1,33 @@
|
||||||
import { Screen } from "./Intermediate";
|
import { Screen } from "./Intermediate";
|
||||||
|
import { ClipboardModal } from "./modals/Clipboard";
|
||||||
import { ErrorModal } from "./modals/Error";
|
import { ErrorModal } from "./modals/Error";
|
||||||
import { InputModal } from "./modals/Input";
|
import { InputModal } from "./modals/Input";
|
||||||
|
import { OnboardingModal } from "./modals/Onboarding";
|
||||||
import { PromptModal } from "./modals/Prompt";
|
import { PromptModal } from "./modals/Prompt";
|
||||||
import { SignedOutModal } from "./modals/SignedOut";
|
import { SignedOutModal } from "./modals/SignedOut";
|
||||||
import { ClipboardModal } from "./modals/Clipboard";
|
|
||||||
import { OnboardingModal } from "./modals/Onboarding";
|
|
||||||
|
|
||||||
export interface Props {
|
export interface Props {
|
||||||
screen: Screen;
|
screen: Screen;
|
||||||
openScreen: (id: any) => void;
|
openScreen: (id: any) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Modals({ screen, openScreen }: Props) {
|
export default function Modals({ screen, openScreen }: Props) {
|
||||||
const onClose = () => openScreen({ id: "none" });
|
const onClose = () => openScreen({ id: "none" });
|
||||||
|
|
||||||
switch (screen.id) {
|
switch (screen.id) {
|
||||||
case "_prompt":
|
case "_prompt":
|
||||||
return <PromptModal onClose={onClose} {...screen} />;
|
return <PromptModal onClose={onClose} {...screen} />;
|
||||||
case "_input":
|
case "_input":
|
||||||
return <InputModal onClose={onClose} {...screen} />;
|
return <InputModal onClose={onClose} {...screen} />;
|
||||||
case "error":
|
case "error":
|
||||||
return <ErrorModal onClose={onClose} {...screen} />;
|
return <ErrorModal onClose={onClose} {...screen} />;
|
||||||
case "signed_out":
|
case "signed_out":
|
||||||
return <SignedOutModal onClose={onClose} {...screen} />;
|
return <SignedOutModal onClose={onClose} {...screen} />;
|
||||||
case "clipboard":
|
case "clipboard":
|
||||||
return <ClipboardModal onClose={onClose} {...screen} />;
|
return <ClipboardModal onClose={onClose} {...screen} />;
|
||||||
case "onboarding":
|
case "onboarding":
|
||||||
return <OnboardingModal onClose={onClose} {...screen} />;
|
return <OnboardingModal onClose={onClose} {...screen} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,39 +1,39 @@
|
||||||
import { IntermediateContext, useIntermediate } from "./Intermediate";
|
|
||||||
import { useContext } from "preact/hooks";
|
import { useContext } from "preact/hooks";
|
||||||
|
|
||||||
import { UserPicker } from "./popovers/UserPicker";
|
import { IntermediateContext, useIntermediate } from "./Intermediate";
|
||||||
import { SpecialInputModal } from "./modals/Input";
|
import { SpecialInputModal } from "./modals/Input";
|
||||||
import { SpecialPromptModal } from "./modals/Prompt";
|
import { SpecialPromptModal } from "./modals/Prompt";
|
||||||
import { UserProfile } from "./popovers/UserProfile";
|
|
||||||
import { ImageViewer } from "./popovers/ImageViewer";
|
|
||||||
import { ChannelInfo } from "./popovers/ChannelInfo";
|
import { ChannelInfo } from "./popovers/ChannelInfo";
|
||||||
import { PendingRequests } from "./popovers/PendingRequests";
|
import { ImageViewer } from "./popovers/ImageViewer";
|
||||||
import { ModifyAccountModal } from "./popovers/ModifyAccount";
|
import { ModifyAccountModal } from "./popovers/ModifyAccount";
|
||||||
|
import { PendingRequests } from "./popovers/PendingRequests";
|
||||||
|
import { UserPicker } from "./popovers/UserPicker";
|
||||||
|
import { UserProfile } from "./popovers/UserProfile";
|
||||||
|
|
||||||
export default function Popovers() {
|
export default function Popovers() {
|
||||||
const { screen } = useContext(IntermediateContext);
|
const { screen } = useContext(IntermediateContext);
|
||||||
const { openScreen } = useIntermediate();
|
const { openScreen } = useIntermediate();
|
||||||
|
|
||||||
const onClose = () => openScreen({ id: "none" });
|
const onClose = () => openScreen({ id: "none" });
|
||||||
|
|
||||||
switch (screen.id) {
|
switch (screen.id) {
|
||||||
case "profile":
|
case "profile":
|
||||||
return <UserProfile {...screen} onClose={onClose} />;
|
return <UserProfile {...screen} onClose={onClose} />;
|
||||||
case "user_picker":
|
case "user_picker":
|
||||||
return <UserPicker {...screen} onClose={onClose} />;
|
return <UserPicker {...screen} onClose={onClose} />;
|
||||||
case "image_viewer":
|
case "image_viewer":
|
||||||
return <ImageViewer {...screen} onClose={onClose} />;
|
return <ImageViewer {...screen} onClose={onClose} />;
|
||||||
case "channel_info":
|
case "channel_info":
|
||||||
return <ChannelInfo {...screen} onClose={onClose} />;
|
return <ChannelInfo {...screen} onClose={onClose} />;
|
||||||
case "pending_requests":
|
case "pending_requests":
|
||||||
return <PendingRequests {...screen} onClose={onClose} />;
|
return <PendingRequests {...screen} onClose={onClose} />;
|
||||||
case "modify_account":
|
case "modify_account":
|
||||||
return <ModifyAccountModal onClose={onClose} {...screen} />;
|
return <ModifyAccountModal onClose={onClose} {...screen} />;
|
||||||
case "special_prompt":
|
case "special_prompt":
|
||||||
return <SpecialPromptModal onClose={onClose} {...screen} />;
|
return <SpecialPromptModal onClose={onClose} {...screen} />;
|
||||||
case "special_input":
|
case "special_input":
|
||||||
return <SpecialInputModal onClose={onClose} {...screen} />;
|
return <SpecialInputModal onClose={onClose} {...screen} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,32 +1,32 @@
|
||||||
import { Text } from "preact-i18n";
|
import { Text } from "preact-i18n";
|
||||||
|
|
||||||
import Modal from "../../../components/ui/Modal";
|
import Modal from "../../../components/ui/Modal";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClipboardModal({ onClose, text }: Props) {
|
export function ClipboardModal({ onClose, text }: Props) {
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
visible={true}
|
visible={true}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={<Text id="app.special.modals.clipboard.unavailable" />}
|
title={<Text id="app.special.modals.clipboard.unavailable" />}
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
onClick: onClose,
|
onClick: onClose,
|
||||||
confirmation: true,
|
confirmation: true,
|
||||||
text: <Text id="app.special.modals.actions.close" />
|
text: <Text id="app.special.modals.actions.close" />,
|
||||||
}
|
},
|
||||||
]}
|
]}>
|
||||||
>
|
{location.protocol !== "https:" && (
|
||||||
{location.protocol !== "https:" && (
|
<p>
|
||||||
<p>
|
<Text id="app.special.modals.clipboard.https" />
|
||||||
<Text id="app.special.modals.clipboard.https" />
|
</p>
|
||||||
</p>
|
)}
|
||||||
)}
|
<Text id="app.special.modals.clipboard.copy" />{" "}
|
||||||
<Text id="app.special.modals.clipboard.copy" />{" "}
|
<code style={{ userSelect: "all" }}>{text}</code>
|
||||||
<code style={{ userSelect: "all" }}>{text}</code>
|
</Modal>
|
||||||
</Modal>
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,30 +1,30 @@
|
||||||
import { Text } from "preact-i18n";
|
import { Text } from "preact-i18n";
|
||||||
|
|
||||||
import Modal from "../../../components/ui/Modal";
|
import Modal from "../../../components/ui/Modal";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
error: string;
|
error: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorModal({ onClose, error }: Props) {
|
export function ErrorModal({ onClose, error }: Props) {
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
visible={true}
|
visible={true}
|
||||||
onClose={() => false}
|
onClose={() => false}
|
||||||
title={<Text id="app.special.modals.error" />}
|
title={<Text id="app.special.modals.error" />}
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
onClick: onClose,
|
onClick: onClose,
|
||||||
confirmation: true,
|
confirmation: true,
|
||||||
text: <Text id="app.special.modals.actions.ok" />
|
text: <Text id="app.special.modals.actions.ok" />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onClick: () => location.reload(),
|
onClick: () => location.reload(),
|
||||||
text: <Text id="app.special.modals.actions.reload" />
|
text: <Text id="app.special.modals.actions.reload" />,
|
||||||
}
|
},
|
||||||
]}
|
]}>
|
||||||
>
|
<Text id={`error.${error}`}>{error}</Text>
|
||||||
<Text id={`error.${error}`}>{error}</Text>
|
</Modal>
|
||||||
</Modal>
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,154 +1,176 @@
|
||||||
import { ulid } from "ulid";
|
|
||||||
import { Text } from "preact-i18n";
|
|
||||||
import { useHistory } from "react-router";
|
import { useHistory } from "react-router";
|
||||||
import Modal from "../../../components/ui/Modal";
|
import { ulid } from "ulid";
|
||||||
import { Children } from "../../../types/Preact";
|
|
||||||
import { takeError } from "../../revoltjs/util";
|
import { Text } from "preact-i18n";
|
||||||
import { useContext, useState } from "preact/hooks";
|
import { useContext, useState } from "preact/hooks";
|
||||||
import Overline from '../../../components/ui/Overline';
|
|
||||||
import InputBox from '../../../components/ui/InputBox';
|
import InputBox from "../../../components/ui/InputBox";
|
||||||
|
import Modal from "../../../components/ui/Modal";
|
||||||
|
import Overline from "../../../components/ui/Overline";
|
||||||
|
|
||||||
|
import { Children } from "../../../types/Preact";
|
||||||
import { AppContext } from "../../revoltjs/RevoltClient";
|
import { AppContext } from "../../revoltjs/RevoltClient";
|
||||||
|
import { takeError } from "../../revoltjs/util";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
question: Children;
|
question: Children;
|
||||||
field?: Children;
|
field?: Children;
|
||||||
defaultValue?: string;
|
defaultValue?: string;
|
||||||
callback: (value: string) => Promise<void>;
|
callback: (value: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function InputModal({
|
export function InputModal({
|
||||||
onClose,
|
onClose,
|
||||||
question,
|
question,
|
||||||
field,
|
field,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
callback
|
callback,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
const [value, setValue] = useState(defaultValue ?? "");
|
const [value, setValue] = useState(defaultValue ?? "");
|
||||||
const [error, setError] = useState<undefined | string>(undefined);
|
const [error, setError] = useState<undefined | string>(undefined);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
visible={true}
|
visible={true}
|
||||||
title={question}
|
title={question}
|
||||||
disabled={processing}
|
disabled={processing}
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
confirmation: true,
|
confirmation: true,
|
||||||
text: <Text id="app.special.modals.actions.ok" />,
|
text: <Text id="app.special.modals.actions.ok" />,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
callback(value)
|
callback(value)
|
||||||
.then(onClose)
|
.then(onClose)
|
||||||
.catch(err => {
|
.catch((err) => {
|
||||||
setError(takeError(err));
|
setError(takeError(err));
|
||||||
setProcessing(false)
|
setProcessing(false);
|
||||||
})
|
});
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: <Text id="app.special.modals.actions.cancel" />,
|
text: <Text id="app.special.modals.actions.cancel" />,
|
||||||
onClick: onClose
|
onClick: onClose,
|
||||||
}
|
},
|
||||||
]}
|
]}
|
||||||
onClose={onClose}
|
onClose={onClose}>
|
||||||
>
|
<form>
|
||||||
<form>
|
{field ? (
|
||||||
{ field ? <Overline error={error} block>
|
<Overline error={error} block>
|
||||||
{field}
|
{field}
|
||||||
</Overline> : (error && <Overline error={error} type="error" block />) }
|
</Overline>
|
||||||
<InputBox
|
) : (
|
||||||
value={value}
|
error && <Overline error={error} type="error" block />
|
||||||
onChange={e => setValue(e.currentTarget.value)}
|
)}
|
||||||
/>
|
<InputBox
|
||||||
</form>
|
value={value}
|
||||||
</Modal>
|
onChange={(e) => setValue(e.currentTarget.value)}
|
||||||
);
|
/>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type SpecialProps = { onClose: () => void } & (
|
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) {
|
export function SpecialInputModal(props: SpecialProps) {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const client = useContext(AppContext);
|
const client = useContext(AppContext);
|
||||||
|
|
||||||
const { onClose } = props;
|
const { onClose } = props;
|
||||||
switch (props.type) {
|
switch (props.type) {
|
||||||
case "create_group": {
|
case "create_group": {
|
||||||
return <InputModal
|
return (
|
||||||
onClose={onClose}
|
<InputModal
|
||||||
question={<Text id="app.main.groups.create" />}
|
onClose={onClose}
|
||||||
field={<Text id="app.main.groups.name" />}
|
question={<Text id="app.main.groups.create" />}
|
||||||
callback={async name => {
|
field={<Text id="app.main.groups.name" />}
|
||||||
const group = await client.channels.createGroup(
|
callback={async (name) => {
|
||||||
{
|
const group = await client.channels.createGroup({
|
||||||
name,
|
name,
|
||||||
nonce: ulid(),
|
nonce: ulid(),
|
||||||
users: []
|
users: [],
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
history.push(`/channel/${group._id}`);
|
history.push(`/channel/${group._id}`);
|
||||||
}}
|
}}
|
||||||
/>;
|
/>
|
||||||
}
|
);
|
||||||
case "create_server": {
|
}
|
||||||
return <InputModal
|
case "create_server": {
|
||||||
onClose={onClose}
|
return (
|
||||||
question={<Text id="app.main.servers.create" />}
|
<InputModal
|
||||||
field={<Text id="app.main.servers.name" />}
|
onClose={onClose}
|
||||||
callback={async name => {
|
question={<Text id="app.main.servers.create" />}
|
||||||
const server = await client.servers.createServer(
|
field={<Text id="app.main.servers.name" />}
|
||||||
{
|
callback={async (name) => {
|
||||||
name,
|
const server = await client.servers.createServer({
|
||||||
nonce: ulid()
|
name,
|
||||||
}
|
nonce: ulid(),
|
||||||
);
|
});
|
||||||
|
|
||||||
history.push(`/server/${server._id}`);
|
history.push(`/server/${server._id}`);
|
||||||
}}
|
}}
|
||||||
/>;
|
/>
|
||||||
}
|
);
|
||||||
case "create_role": {
|
}
|
||||||
return <InputModal
|
case "create_role": {
|
||||||
onClose={onClose}
|
return (
|
||||||
question={<Text id="app.settings.permissions.create_role" />}
|
<InputModal
|
||||||
field={<Text id="app.settings.permissions.role_name" />}
|
onClose={onClose}
|
||||||
callback={async name => {
|
question={
|
||||||
const role = await client.servers.createRole(props.server, name);
|
<Text id="app.settings.permissions.create_role" />
|
||||||
props.callback(role.id);
|
}
|
||||||
}}
|
field={<Text id="app.settings.permissions.role_name" />}
|
||||||
/>;
|
callback={async (name) => {
|
||||||
}
|
const role = await client.servers.createRole(
|
||||||
case "set_custom_status": {
|
props.server,
|
||||||
return <InputModal
|
name,
|
||||||
onClose={onClose}
|
);
|
||||||
question={<Text id="app.context_menu.set_custom_status" />}
|
props.callback(role.id);
|
||||||
field={<Text id="app.context_menu.custom_status" />}
|
}}
|
||||||
defaultValue={client.user?.status?.text}
|
/>
|
||||||
callback={text =>
|
);
|
||||||
client.users.editUser({
|
}
|
||||||
status: {
|
case "set_custom_status": {
|
||||||
...client.user?.status,
|
return (
|
||||||
text: text.trim().length > 0 ? text : undefined
|
<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) =>
|
||||||
case "add_friend": {
|
client.users.editUser({
|
||||||
return <InputModal
|
status: {
|
||||||
onClose={onClose}
|
...client.user?.status,
|
||||||
question={"Add Friend"}
|
text: text.trim().length > 0 ? text : undefined,
|
||||||
callback={username =>
|
},
|
||||||
client.users.addFriend(username)
|
})
|
||||||
}
|
}
|
||||||
/>;
|
/>
|
||||||
}
|
);
|
||||||
default: return null;
|
}
|
||||||
}
|
case "add_friend": {
|
||||||
|
return (
|
||||||
|
<InputModal
|
||||||
|
onClose={onClose}
|
||||||
|
question={"Add Friend"}
|
||||||
|
callback={(username) => client.users.addFriend(username)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,71 +1,78 @@
|
||||||
|
import { SubmitHandler, useForm } from "react-hook-form";
|
||||||
|
|
||||||
|
import styles from "./Onboarding.module.scss";
|
||||||
import { Text } from "preact-i18n";
|
import { Text } from "preact-i18n";
|
||||||
import { useState } from "preact/hooks";
|
import { useState } from "preact/hooks";
|
||||||
import { SubmitHandler, useForm } from "react-hook-form";
|
|
||||||
import styles from "./Onboarding.module.scss";
|
import wideSVG from "../../../assets/wide.svg";
|
||||||
import { takeError } from "../../revoltjs/util";
|
|
||||||
import Button from "../../../components/ui/Button";
|
import Button from "../../../components/ui/Button";
|
||||||
import FormField from "../../../pages/login/FormField";
|
|
||||||
import Preloader from "../../../components/ui/Preloader";
|
import Preloader from "../../../components/ui/Preloader";
|
||||||
|
|
||||||
import wideSVG from '../../../assets/wide.svg';
|
import FormField from "../../../pages/login/FormField";
|
||||||
|
import { takeError } from "../../revoltjs/util";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
callback: (username: string, loginAfterSuccess?: true) => Promise<void>;
|
callback: (username: string, loginAfterSuccess?: true) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FormInputs {
|
interface FormInputs {
|
||||||
username: string
|
username: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OnboardingModal({ onClose, callback }: Props) {
|
export function OnboardingModal({ onClose, callback }: Props) {
|
||||||
const { handleSubmit, register } = useForm<FormInputs>();
|
const { handleSubmit, register } = useForm<FormInputs>();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | undefined>(undefined);
|
const [error, setError] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
const onSubmit: SubmitHandler<FormInputs> = ({ username }) => {
|
const onSubmit: SubmitHandler<FormInputs> = ({ username }) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
callback(username, true)
|
callback(username, true)
|
||||||
.then(onClose)
|
.then(onClose)
|
||||||
.catch((err: any) => {
|
.catch((err: any) => {
|
||||||
setError(takeError(err));
|
setError(takeError(err));
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.onboarding}>
|
<div className={styles.onboarding}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<h1>
|
<h1>
|
||||||
<Text id="app.special.modals.onboarding.welcome" />
|
<Text id="app.special.modals.onboarding.welcome" />
|
||||||
<img src={wideSVG} />
|
<img src={wideSVG} />
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.form}>
|
<div className={styles.form}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Preloader type="spinner" />
|
<Preloader type="spinner" />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p>
|
<p>
|
||||||
<Text id="app.special.modals.onboarding.pick" />
|
<Text id="app.special.modals.onboarding.pick" />
|
||||||
</p>
|
</p>
|
||||||
<form onSubmit={handleSubmit(onSubmit) as JSX.GenericEventHandler<HTMLFormElement>}>
|
<form
|
||||||
<div>
|
onSubmit={
|
||||||
<FormField
|
handleSubmit(
|
||||||
type="username"
|
onSubmit,
|
||||||
register={register}
|
) as JSX.GenericEventHandler<HTMLFormElement>
|
||||||
showOverline
|
}>
|
||||||
error={error}
|
<div>
|
||||||
/>
|
<FormField
|
||||||
</div>
|
type="username"
|
||||||
<Button type="submit">
|
register={register}
|
||||||
<Text id="app.special.modals.actions.continue" />
|
showOverline
|
||||||
</Button>
|
error={error}
|
||||||
</form>
|
/>
|
||||||
</>
|
</div>
|
||||||
)}
|
<Button type="submit">
|
||||||
</div>
|
<Text id="app.special.modals.actions.continue" />
|
||||||
<div />
|
</Button>
|
||||||
</div>
|
</form>
|
||||||
);
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,346 +1,473 @@
|
||||||
import { ulid } from "ulid";
|
|
||||||
import { Text } from "preact-i18n";
|
|
||||||
import styles from './Prompt.module.scss';
|
|
||||||
import { useHistory } from "react-router-dom";
|
import { useHistory } from "react-router-dom";
|
||||||
import Radio from "../../../components/ui/Radio";
|
|
||||||
import { Children } from "../../../types/Preact";
|
|
||||||
import { useIntermediate } from "../Intermediate";
|
|
||||||
import InputBox from "../../../components/ui/InputBox";
|
|
||||||
import Overline from "../../../components/ui/Overline";
|
|
||||||
import { AppContext } from "../../revoltjs/RevoltClient";
|
|
||||||
import { mapMessage, takeError } from "../../revoltjs/util";
|
|
||||||
import Modal, { Action } from "../../../components/ui/Modal";
|
|
||||||
import { Channels, Servers, Users } from "revolt.js/dist/api/objects";
|
import { Channels, Servers, Users } from "revolt.js/dist/api/objects";
|
||||||
|
import { ulid } from "ulid";
|
||||||
|
|
||||||
|
import styles from "./Prompt.module.scss";
|
||||||
|
import { Text } from "preact-i18n";
|
||||||
import { useContext, useEffect, useState } from "preact/hooks";
|
import { useContext, useEffect, useState } from "preact/hooks";
|
||||||
import UserIcon from "../../../components/common/user/UserIcon";
|
|
||||||
import Message from "../../../components/common/messaging/Message";
|
|
||||||
import { TextReact } from "../../../lib/i18n";
|
import { TextReact } from "../../../lib/i18n";
|
||||||
|
|
||||||
|
import Message from "../../../components/common/messaging/Message";
|
||||||
|
import UserIcon from "../../../components/common/user/UserIcon";
|
||||||
|
import InputBox from "../../../components/ui/InputBox";
|
||||||
|
import Modal, { Action } from "../../../components/ui/Modal";
|
||||||
|
import Overline from "../../../components/ui/Overline";
|
||||||
|
import Radio from "../../../components/ui/Radio";
|
||||||
|
|
||||||
|
import { Children } from "../../../types/Preact";
|
||||||
|
import { AppContext } from "../../revoltjs/RevoltClient";
|
||||||
|
import { mapMessage, takeError } from "../../revoltjs/util";
|
||||||
|
import { useIntermediate } from "../Intermediate";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
question: Children;
|
question: Children;
|
||||||
content?: Children;
|
content?: Children;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
actions: Action[];
|
actions: Action[];
|
||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PromptModal({ onClose, question, content, actions, disabled, error }: Props) {
|
export function PromptModal({
|
||||||
return (
|
onClose,
|
||||||
<Modal
|
question,
|
||||||
visible={true}
|
content,
|
||||||
title={question}
|
actions,
|
||||||
actions={actions}
|
disabled,
|
||||||
onClose={onClose}
|
error,
|
||||||
disabled={disabled}>
|
}: Props) {
|
||||||
{ error && <Overline error={error} type="error" /> }
|
return (
|
||||||
{ content }
|
<Modal
|
||||||
</Modal>
|
visible={true}
|
||||||
);
|
title={question}
|
||||||
|
actions={actions}
|
||||||
|
onClose={onClose}
|
||||||
|
disabled={disabled}>
|
||||||
|
{error && <Overline error={error} type="error" />}
|
||||||
|
{content}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type SpecialProps = { onClose: () => void } & (
|
type SpecialProps = { onClose: () => void } & (
|
||||||
{ type: "leave_group", target: Channels.GroupChannel } |
|
| { type: "leave_group"; target: Channels.GroupChannel }
|
||||||
{ type: "close_dm", target: Channels.DirectMessageChannel } |
|
| { type: "close_dm"; target: Channels.DirectMessageChannel }
|
||||||
{ type: "leave_server", target: Servers.Server } |
|
| { type: "leave_server"; target: Servers.Server }
|
||||||
{ type: "delete_server", target: Servers.Server } |
|
| { type: "delete_server"; target: Servers.Server }
|
||||||
{ type: "delete_channel", target: Channels.TextChannel } |
|
| { type: "delete_channel"; target: Channels.TextChannel }
|
||||||
{ type: "delete_message", target: Channels.Message } |
|
| { type: "delete_message"; target: Channels.Message }
|
||||||
{ type: "create_invite", target: Channels.TextChannel | Channels.GroupChannel } |
|
| {
|
||||||
{ type: "kick_member", target: Servers.Server, user: string } |
|
type: "create_invite";
|
||||||
{ type: "ban_member", target: Servers.Server, user: string } |
|
target: Channels.TextChannel | Channels.GroupChannel;
|
||||||
{ type: "unfriend_user", target: Users.User } |
|
}
|
||||||
{ type: "block_user", target: Users.User } |
|
| { type: "kick_member"; target: Servers.Server; user: string }
|
||||||
{ type: "create_channel", target: Servers.Server }
|
| { 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) {
|
export function SpecialPromptModal(props: SpecialProps) {
|
||||||
const client = useContext(AppContext);
|
const client = useContext(AppContext);
|
||||||
const [ processing, setProcessing ] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
const [ error, setError ] = useState<undefined | string>(undefined);
|
const [error, setError] = useState<undefined | string>(undefined);
|
||||||
|
|
||||||
const { onClose } = props;
|
const { onClose } = props;
|
||||||
switch (props.type) {
|
switch (props.type) {
|
||||||
case 'leave_group':
|
case "leave_group":
|
||||||
case 'close_dm':
|
case "close_dm":
|
||||||
case 'leave_server':
|
case "leave_server":
|
||||||
case 'delete_server':
|
case "delete_server":
|
||||||
case 'delete_channel':
|
case "delete_channel":
|
||||||
case 'unfriend_user':
|
case "unfriend_user":
|
||||||
case 'block_user': {
|
case "block_user": {
|
||||||
const EVENTS = {
|
const EVENTS = {
|
||||||
'close_dm': ['confirm_close_dm', 'close'],
|
close_dm: ["confirm_close_dm", "close"],
|
||||||
'delete_server': ['confirm_delete', 'delete'],
|
delete_server: ["confirm_delete", "delete"],
|
||||||
'delete_channel': ['confirm_delete', 'delete'],
|
delete_channel: ["confirm_delete", "delete"],
|
||||||
'leave_group': ['confirm_leave', 'leave'],
|
leave_group: ["confirm_leave", "leave"],
|
||||||
'leave_server': ['confirm_leave', 'leave'],
|
leave_server: ["confirm_leave", "leave"],
|
||||||
'unfriend_user': ['unfriend_user', 'remove'],
|
unfriend_user: ["unfriend_user", "remove"],
|
||||||
'block_user': ['block_user', 'block']
|
block_user: ["block_user", "block"],
|
||||||
};
|
};
|
||||||
|
|
||||||
let event = EVENTS[props.type];
|
let event = EVENTS[props.type];
|
||||||
let name;
|
let name;
|
||||||
switch (props.type) {
|
switch (props.type) {
|
||||||
case 'unfriend_user':
|
case "unfriend_user":
|
||||||
case 'block_user': name = props.target.username; break;
|
case "block_user":
|
||||||
case 'close_dm': name = client.users.get(client.channels.getRecipient(props.target._id))?.username; break;
|
name = props.target.username;
|
||||||
default: name = props.target.name;
|
break;
|
||||||
}
|
case "close_dm":
|
||||||
|
name = client.users.get(
|
||||||
|
client.channels.getRecipient(props.target._id),
|
||||||
|
)?.username;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
name = props.target.name;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PromptModal
|
<PromptModal
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
question={<Text
|
question={
|
||||||
id={`app.special.modals.prompt.${event[0]}`}
|
<Text
|
||||||
fields={{ name }}
|
id={`app.special.modals.prompt.${event[0]}`}
|
||||||
/>}
|
fields={{ name }}
|
||||||
actions={[
|
/>
|
||||||
{
|
}
|
||||||
confirmation: true,
|
actions={[
|
||||||
contrast: true,
|
{
|
||||||
error: true,
|
confirmation: true,
|
||||||
text: <Text id={`app.special.modals.actions.${event[1]}`} />,
|
contrast: true,
|
||||||
onClick: async () => {
|
error: true,
|
||||||
setProcessing(true);
|
text: (
|
||||||
|
<Text
|
||||||
|
id={`app.special.modals.actions.${event[1]}`}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
onClick: async () => {
|
||||||
|
setProcessing(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
switch (props.type) {
|
switch (props.type) {
|
||||||
case 'unfriend_user':
|
case "unfriend_user":
|
||||||
await client.users.removeFriend(props.target._id); break;
|
await client.users.removeFriend(
|
||||||
case 'block_user':
|
props.target._id,
|
||||||
await client.users.blockUser(props.target._id); break;
|
);
|
||||||
case 'leave_group':
|
break;
|
||||||
case 'close_dm':
|
case "block_user":
|
||||||
case 'delete_channel':
|
await client.users.blockUser(
|
||||||
await client.channels.delete(props.target._id); break;
|
props.target._id,
|
||||||
case 'leave_server':
|
);
|
||||||
case 'delete_server':
|
break;
|
||||||
await client.servers.delete(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();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(takeError(err));
|
setError(takeError(err));
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{ text: <Text id="app.special.modals.actions.cancel" />, onClick: onClose }
|
{
|
||||||
]}
|
text: (
|
||||||
content={<TextReact id={`app.special.modals.prompt.${event[0]}_long`} fields={{ name: <b>{ name }</b> }} />}
|
<Text id="app.special.modals.actions.cancel" />
|
||||||
disabled={processing}
|
),
|
||||||
error={error}
|
onClick: onClose,
|
||||||
/>
|
},
|
||||||
)
|
]}
|
||||||
}
|
content={
|
||||||
case 'delete_message': {
|
<TextReact
|
||||||
return (
|
id={`app.special.modals.prompt.${event[0]}_long`}
|
||||||
<PromptModal
|
fields={{ name: <b>{name}</b> }}
|
||||||
onClose={onClose}
|
/>
|
||||||
question={<Text id={'app.context_menu.delete_message'} />}
|
}
|
||||||
actions={[
|
disabled={processing}
|
||||||
{
|
error={error}
|
||||||
confirmation: true,
|
/>
|
||||||
contrast: true,
|
);
|
||||||
error: true,
|
}
|
||||||
text: <Text id="app.special.modals.actions.delete" />,
|
case "delete_message": {
|
||||||
onClick: async () => {
|
return (
|
||||||
setProcessing(true);
|
<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 {
|
try {
|
||||||
await client.channels.deleteMessage(props.target.channel, props.target._id);
|
await client.channels.deleteMessage(
|
||||||
|
props.target.channel,
|
||||||
|
props.target._id,
|
||||||
|
);
|
||||||
|
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(takeError(err));
|
setError(takeError(err));
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
{ text: <Text id="app.special.modals.actions.cancel" />, onClick: onClose }
|
{
|
||||||
]}
|
text: (
|
||||||
content={<>
|
<Text id="app.special.modals.actions.cancel" />
|
||||||
<Text id={`app.special.modals.prompt.confirm_delete_message_long`} />
|
),
|
||||||
<Message message={mapMessage(props.target)} head={true} contrast />
|
onClick: onClose,
|
||||||
</>}
|
},
|
||||||
disabled={processing}
|
]}
|
||||||
error={error}
|
content={
|
||||||
/>
|
<>
|
||||||
)
|
<Text
|
||||||
}
|
id={`app.special.modals.prompt.confirm_delete_message_long`}
|
||||||
case "create_invite": {
|
/>
|
||||||
const [ code, setCode ] = useState('abcdef');
|
<Message
|
||||||
const { writeClipboard } = useIntermediate();
|
message={mapMessage(props.target)}
|
||||||
|
head={true}
|
||||||
|
contrast
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
disabled={processing}
|
||||||
|
error={error}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "create_invite": {
|
||||||
|
const [code, setCode] = useState("abcdef");
|
||||||
|
const { writeClipboard } = useIntermediate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setProcessing(true);
|
setProcessing(true);
|
||||||
|
|
||||||
client.channels.createInvite(props.target._id)
|
client.channels
|
||||||
.then(code => setCode(code))
|
.createInvite(props.target._id)
|
||||||
.catch(err => setError(takeError(err)))
|
.then((code) => setCode(code))
|
||||||
.finally(() => setProcessing(false));
|
.catch((err) => setError(takeError(err)))
|
||||||
}, []);
|
.finally(() => setProcessing(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PromptModal
|
<PromptModal
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
question={<Text id={`app.context_menu.create_invite`} />}
|
question={<Text id={`app.context_menu.create_invite`} />}
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
text: <Text id="app.special.modals.actions.ok" />,
|
text: <Text id="app.special.modals.actions.ok" />,
|
||||||
confirmation: true,
|
confirmation: true,
|
||||||
onClick: onClose
|
onClick: onClose,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: <Text id="app.context_menu.copy_link" />,
|
text: <Text id="app.context_menu.copy_link" />,
|
||||||
onClick: () => writeClipboard(`${window.location.protocol}//${window.location.host}/invite/${code}`)
|
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}>
|
content={
|
||||||
<Text id="app.special.modals.prompt.create_invite_created" />
|
processing ? (
|
||||||
<code>{code}</code>
|
<Text id="app.special.modals.prompt.create_invite_generate" />
|
||||||
</div>
|
) : (
|
||||||
}
|
<div className={styles.invite}>
|
||||||
disabled={processing}
|
<Text id="app.special.modals.prompt.create_invite_created" />
|
||||||
error={error}
|
<code>{code}</code>
|
||||||
/>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case "kick_member": {
|
disabled={processing}
|
||||||
const user = client.users.get(props.user);
|
error={error}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "kick_member": {
|
||||||
|
const user = client.users.get(props.user);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PromptModal
|
<PromptModal
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
question={<Text id={`app.context_menu.kick_member`} />}
|
question={<Text id={`app.context_menu.kick_member`} />}
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
text: <Text id="app.special.modals.actions.kick" />,
|
text: <Text id="app.special.modals.actions.kick" />,
|
||||||
contrast: true,
|
contrast: true,
|
||||||
error: true,
|
error: true,
|
||||||
confirmation: true,
|
confirmation: true,
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
setProcessing(true);
|
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);
|
|
||||||
|
|
||||||
return (
|
try {
|
||||||
<PromptModal
|
await client.servers.members.kickMember(
|
||||||
onClose={onClose}
|
props.target._id,
|
||||||
question={<Text id={`app.context_menu.ban_member`} />}
|
props.user,
|
||||||
actions={[
|
);
|
||||||
{
|
onClose();
|
||||||
text: <Text id="app.special.modals.actions.ban" />,
|
} catch (err) {
|
||||||
contrast: true,
|
setError(takeError(err));
|
||||||
error: true,
|
setProcessing(false);
|
||||||
confirmation: true,
|
}
|
||||||
onClick: async () => {
|
},
|
||||||
setProcessing(true);
|
},
|
||||||
|
{
|
||||||
try {
|
text: (
|
||||||
await client.servers.banUser(props.target._id, props.user, { reason });
|
<Text id="app.special.modals.actions.cancel" />
|
||||||
onClose();
|
),
|
||||||
} catch (err) {
|
onClick: onClose,
|
||||||
setError(takeError(err));
|
},
|
||||||
setProcessing(false);
|
]}
|
||||||
}
|
content={
|
||||||
}
|
<div className={styles.column}>
|
||||||
},
|
<UserIcon target={user} size={64} />
|
||||||
{ text: <Text id="app.special.modals.actions.cancel" />, onClick: onClose }
|
<Text
|
||||||
]}
|
id="app.special.modals.prompt.confirm_kick"
|
||||||
content={<div className={styles.column}>
|
fields={{ name: user?.username }}
|
||||||
<UserIcon target={user} size={64} />
|
/>
|
||||||
<Text
|
</div>
|
||||||
id="app.special.modals.prompt.confirm_ban"
|
}
|
||||||
fields={{ name: user?.username }} />
|
disabled={processing}
|
||||||
<Overline><Text id="app.special.modals.prompt.confirm_ban_reason" /></Overline>
|
error={error}
|
||||||
<InputBox value={reason ?? ''} onChange={e => setReason(e.currentTarget.value)} />
|
/>
|
||||||
</div>}
|
);
|
||||||
disabled={processing}
|
}
|
||||||
error={error}
|
case "ban_member": {
|
||||||
/>
|
const [reason, setReason] = useState<string | undefined>(undefined);
|
||||||
)
|
const user = client.users.get(props.user);
|
||||||
}
|
|
||||||
case 'create_channel': {
|
|
||||||
const [ name, setName ] = useState('');
|
|
||||||
const [ type, setType ] = useState<'Text' | 'Voice'>('Text');
|
|
||||||
const history = useHistory();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PromptModal
|
<PromptModal
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
question={<Text id="app.context_menu.create_channel" />}
|
question={<Text id={`app.context_menu.ban_member`} />}
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
confirmation: true,
|
text: <Text id="app.special.modals.actions.ban" />,
|
||||||
contrast: true,
|
contrast: true,
|
||||||
text: <Text id="app.special.modals.actions.create" />,
|
error: true,
|
||||||
onClick: async () => {
|
confirmation: true,
|
||||||
setProcessing(true);
|
onClick: async () => {
|
||||||
|
setProcessing(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const channel = await client.servers.createChannel(
|
await client.servers.banUser(
|
||||||
props.target._id,
|
props.target._id,
|
||||||
{
|
props.user,
|
||||||
type,
|
{ reason },
|
||||||
name,
|
);
|
||||||
nonce: ulid()
|
onClose();
|
||||||
}
|
} catch (err) {
|
||||||
);
|
setError(takeError(err));
|
||||||
|
setProcessing(false);
|
||||||
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,
|
||||||
{ text: <Text id="app.special.modals.actions.cancel" />, onClick: onClose }
|
},
|
||||||
]}
|
]}
|
||||||
content={<>
|
content={
|
||||||
<Overline block type="subtle"><Text id="app.main.servers.channel_type" /></Overline>
|
<div className={styles.column}>
|
||||||
<Radio checked={type === 'Text'} onSelect={() => setType('Text')}>
|
<UserIcon target={user} size={64} />
|
||||||
<Text id="app.main.servers.text_channel" /></Radio>
|
<Text
|
||||||
<Radio checked={type === 'Voice'} onSelect={() => setType('Voice')}>
|
id="app.special.modals.prompt.confirm_ban"
|
||||||
<Text id="app.main.servers.voice_channel" /></Radio>
|
fields={{ name: user?.username }}
|
||||||
<Overline block type="subtle"><Text id="app.main.servers.channel_name" /></Overline>
|
/>
|
||||||
<InputBox
|
<Overline>
|
||||||
value={name}
|
<Text id="app.special.modals.prompt.confirm_ban_reason" />
|
||||||
onChange={e => setName(e.currentTarget.value)} />
|
</Overline>
|
||||||
</>}
|
<InputBox
|
||||||
disabled={processing}
|
value={reason ?? ""}
|
||||||
error={error}
|
onChange={(e) =>
|
||||||
/>
|
setReason(e.currentTarget.value)
|
||||||
)
|
}
|
||||||
}
|
/>
|
||||||
default: return null;
|
</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);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,23 +1,24 @@
|
||||||
import { Text } from "preact-i18n";
|
import { Text } from "preact-i18n";
|
||||||
|
|
||||||
import Modal from "../../../components/ui/Modal";
|
import Modal from "../../../components/ui/Modal";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SignedOutModal({ onClose }: Props) {
|
export function SignedOutModal({ onClose }: Props) {
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
visible={true}
|
visible={true}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={<Text id="app.special.modals.signed_out" />}
|
title={<Text id="app.special.modals.signed_out" />}
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
onClick: onClose,
|
onClick: onClose,
|
||||||
confirmation: true,
|
confirmation: true,
|
||||||
text: <Text id="app.special.modals.actions.ok" />
|
text: <Text id="app.special.modals.actions.ok" />,
|
||||||
}
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,38 +1,44 @@
|
||||||
import { X } from "@styled-icons/boxicons-regular";
|
import { X } from "@styled-icons/boxicons-regular";
|
||||||
|
|
||||||
import styles from "./ChannelInfo.module.scss";
|
import styles from "./ChannelInfo.module.scss";
|
||||||
|
|
||||||
import Modal from "../../../components/ui/Modal";
|
import Modal from "../../../components/ui/Modal";
|
||||||
import { getChannelName } from "../../revoltjs/util";
|
|
||||||
import Markdown from "../../../components/markdown/Markdown";
|
import Markdown from "../../../components/markdown/Markdown";
|
||||||
import { useChannel, useForceUpdate } from "../../revoltjs/hooks";
|
import { useChannel, useForceUpdate } from "../../revoltjs/hooks";
|
||||||
|
import { getChannelName } from "../../revoltjs/util";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
channel_id: string;
|
channel_id: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChannelInfo({ channel_id, onClose }: Props) {
|
export function ChannelInfo({ channel_id, onClose }: Props) {
|
||||||
const ctx = useForceUpdate();
|
const ctx = useForceUpdate();
|
||||||
const channel = useChannel(channel_id, ctx);
|
const channel = useChannel(channel_id, ctx);
|
||||||
if (!channel) return null;
|
if (!channel) return null;
|
||||||
|
|
||||||
if (channel.channel_type === "DirectMessage" || channel.channel_type === 'SavedMessages') {
|
if (
|
||||||
onClose();
|
channel.channel_type === "DirectMessage" ||
|
||||||
return null;
|
channel.channel_type === "SavedMessages"
|
||||||
}
|
) {
|
||||||
|
onClose();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal visible={true} onClose={onClose}>
|
<Modal visible={true} onClose={onClose}>
|
||||||
<div className={styles.info}>
|
<div className={styles.info}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<h1>{ getChannelName(ctx.client, channel, true) }</h1>
|
<h1>{getChannelName(ctx.client, channel, true)}</h1>
|
||||||
<div onClick={onClose}>
|
<div onClick={onClose}>
|
||||||
<X size={36} />
|
<X size={36} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
<Markdown content={channel.description} />
|
<Markdown content={channel.description} />
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,43 +1,46 @@
|
||||||
import styles from "./ImageViewer.module.scss";
|
|
||||||
import Modal from "../../../components/ui/Modal";
|
|
||||||
import { useContext, useEffect } from "preact/hooks";
|
|
||||||
import { AppContext } from "../../revoltjs/RevoltClient";
|
|
||||||
import { Attachment, EmbedImage } from "revolt.js/dist/api/objects";
|
import { Attachment, EmbedImage } from "revolt.js/dist/api/objects";
|
||||||
import EmbedMediaActions from "../../../components/common/messaging/embed/EmbedMediaActions";
|
|
||||||
|
import styles from "./ImageViewer.module.scss";
|
||||||
|
import { useContext, useEffect } from "preact/hooks";
|
||||||
|
|
||||||
import AttachmentActions from "../../../components/common/messaging/attachments/AttachmentActions";
|
import AttachmentActions from "../../../components/common/messaging/attachments/AttachmentActions";
|
||||||
|
import EmbedMediaActions from "../../../components/common/messaging/embed/EmbedMediaActions";
|
||||||
|
import Modal from "../../../components/ui/Modal";
|
||||||
|
|
||||||
|
import { AppContext } from "../../revoltjs/RevoltClient";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
embed?: EmbedImage;
|
embed?: EmbedImage;
|
||||||
attachment?: Attachment;
|
attachment?: Attachment;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ImageViewer({ attachment, embed, onClose }: Props) {
|
export function ImageViewer({ attachment, embed, onClose }: Props) {
|
||||||
// ! FIXME: temp code
|
// ! FIXME: temp code
|
||||||
// ! add proxy function to client
|
// ! add proxy function to client
|
||||||
function proxyImage(url: string) {
|
function proxyImage(url: string) {
|
||||||
return 'https://jan.revolt.chat/proxy?url=' + encodeURIComponent(url);
|
return "https://jan.revolt.chat/proxy?url=" + encodeURIComponent(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (attachment && attachment.metadata.type !== "Image") return null;
|
|
||||||
const client = useContext(AppContext);
|
|
||||||
|
|
||||||
return (
|
if (attachment && attachment.metadata.type !== "Image") return null;
|
||||||
<Modal visible={true} onClose={onClose} noBackground>
|
const client = useContext(AppContext);
|
||||||
<div className={styles.viewer}>
|
|
||||||
{ attachment &&
|
return (
|
||||||
<>
|
<Modal visible={true} onClose={onClose} noBackground>
|
||||||
<img src={client.generateFileURL(attachment)} />
|
<div className={styles.viewer}>
|
||||||
<AttachmentActions attachment={attachment} />
|
{attachment && (
|
||||||
</>
|
<>
|
||||||
}
|
<img src={client.generateFileURL(attachment)} />
|
||||||
{ embed &&
|
<AttachmentActions attachment={attachment} />
|
||||||
<>
|
</>
|
||||||
<img src={proxyImage(embed.url)} />
|
)}
|
||||||
<EmbedMediaActions embed={embed} />
|
{embed && (
|
||||||
</>
|
<>
|
||||||
}
|
<img src={proxyImage(embed.url)} />
|
||||||
</div>
|
<EmbedMediaActions embed={embed} />
|
||||||
</Modal>
|
</>
|
||||||
);
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,127 +1,134 @@
|
||||||
import { Text } from "preact-i18n";
|
|
||||||
import { SubmitHandler, useForm } from "react-hook-form";
|
import { SubmitHandler, useForm } from "react-hook-form";
|
||||||
import Modal from "../../../components/ui/Modal";
|
|
||||||
import { takeError } from "../../revoltjs/util";
|
import { Text } from "preact-i18n";
|
||||||
import { useContext, useState } from "preact/hooks";
|
import { useContext, useState } from "preact/hooks";
|
||||||
import FormField from '../../../pages/login/FormField';
|
|
||||||
|
import Modal from "../../../components/ui/Modal";
|
||||||
import Overline from "../../../components/ui/Overline";
|
import Overline from "../../../components/ui/Overline";
|
||||||
|
|
||||||
|
import FormField from "../../../pages/login/FormField";
|
||||||
import { AppContext } from "../../revoltjs/RevoltClient";
|
import { AppContext } from "../../revoltjs/RevoltClient";
|
||||||
|
import { takeError } from "../../revoltjs/util";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
field: "username" | "email" | "password";
|
field: "username" | "email" | "password";
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FormInputs {
|
interface FormInputs {
|
||||||
password: string,
|
password: string;
|
||||||
new_email: string,
|
new_email: string;
|
||||||
new_username: string,
|
new_username: string;
|
||||||
new_password: string,
|
new_password: string;
|
||||||
|
|
||||||
// TODO: figure out if this is correct or not
|
// 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
|
// it wasn't in the types before this was typed but the element itself was there
|
||||||
current_password?: string
|
current_password?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModifyAccountModal({ onClose, field }: Props) {
|
export function ModifyAccountModal({ onClose, field }: Props) {
|
||||||
const client = useContext(AppContext);
|
const client = useContext(AppContext);
|
||||||
const { handleSubmit, register, errors } = useForm<FormInputs>();
|
const { handleSubmit, register, errors } = useForm<FormInputs>();
|
||||||
const [error, setError] = useState<string | undefined>(undefined);
|
const [error, setError] = useState<string | undefined>(undefined);
|
||||||
|
|
||||||
const onSubmit: SubmitHandler<FormInputs> = async ({
|
const onSubmit: SubmitHandler<FormInputs> = async ({
|
||||||
password,
|
password,
|
||||||
new_username,
|
new_username,
|
||||||
new_email,
|
new_email,
|
||||||
new_password
|
new_password,
|
||||||
}) => {
|
}) => {
|
||||||
try {
|
try {
|
||||||
if (field === "email") {
|
if (field === "email") {
|
||||||
await client.req("POST", "/auth/change/email", {
|
await client.req("POST", "/auth/change/email", {
|
||||||
password,
|
password,
|
||||||
new_email
|
new_email,
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
} else if (field === "password") {
|
} else if (field === "password") {
|
||||||
await client.req("POST", "/auth/change/password", {
|
await client.req("POST", "/auth/change/password", {
|
||||||
password,
|
password,
|
||||||
new_password
|
new_password,
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
} else if (field === "username") {
|
} else if (field === "username") {
|
||||||
await client.req("PATCH", "/users/id/username", {
|
await client.req("PATCH", "/users/id/username", {
|
||||||
username: new_username,
|
username: new_username,
|
||||||
password
|
password,
|
||||||
});
|
});
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(takeError(err));
|
setError(takeError(err));
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
visible={true}
|
visible={true}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={<Text id={`app.special.modals.account.change.${field}`} />}
|
title={<Text id={`app.special.modals.account.change.${field}`} />}
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
confirmation: true,
|
confirmation: true,
|
||||||
onClick: handleSubmit(onSubmit),
|
onClick: handleSubmit(onSubmit),
|
||||||
text:
|
text:
|
||||||
field === "email" ? (
|
field === "email" ? (
|
||||||
<Text id="app.special.modals.actions.send_email" />
|
<Text id="app.special.modals.actions.send_email" />
|
||||||
) : (
|
) : (
|
||||||
<Text id="app.special.modals.actions.update" />
|
<Text id="app.special.modals.actions.update" />
|
||||||
)
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onClick: onClose,
|
onClick: onClose,
|
||||||
text: <Text id="app.special.modals.actions.close" />
|
text: <Text id="app.special.modals.actions.close" />,
|
||||||
}
|
},
|
||||||
]}
|
]}>
|
||||||
>
|
{/* Preact / React typing incompatabilities */}
|
||||||
{/* Preact / React typing incompatabilities */}
|
<form
|
||||||
<form onSubmit={handleSubmit(onSubmit) as JSX.GenericEventHandler<HTMLFormElement>}>
|
onSubmit={
|
||||||
{field === "email" && (
|
handleSubmit(
|
||||||
<FormField
|
onSubmit,
|
||||||
type="email"
|
) as JSX.GenericEventHandler<HTMLFormElement>
|
||||||
name="new_email"
|
}>
|
||||||
register={register}
|
{field === "email" && (
|
||||||
showOverline
|
<FormField
|
||||||
error={errors.new_email?.message}
|
type="email"
|
||||||
/>
|
name="new_email"
|
||||||
)}
|
register={register}
|
||||||
{field === "password" && (
|
showOverline
|
||||||
<FormField
|
error={errors.new_email?.message}
|
||||||
type="password"
|
/>
|
||||||
name="new_password"
|
)}
|
||||||
register={register}
|
{field === "password" && (
|
||||||
showOverline
|
<FormField
|
||||||
error={errors.new_password?.message}
|
type="password"
|
||||||
/>
|
name="new_password"
|
||||||
)}
|
register={register}
|
||||||
{field === "username" && (
|
showOverline
|
||||||
<FormField
|
error={errors.new_password?.message}
|
||||||
type="username"
|
/>
|
||||||
name="new_username"
|
)}
|
||||||
register={register}
|
{field === "username" && (
|
||||||
showOverline
|
<FormField
|
||||||
error={errors.new_username?.message}
|
type="username"
|
||||||
/>
|
name="new_username"
|
||||||
)}
|
register={register}
|
||||||
<FormField
|
showOverline
|
||||||
type="current_password"
|
error={errors.new_username?.message}
|
||||||
register={register}
|
/>
|
||||||
showOverline
|
)}
|
||||||
error={errors.current_password?.message}
|
<FormField
|
||||||
/>
|
type="current_password"
|
||||||
{error && (
|
register={register}
|
||||||
<Overline type="error" error={error}>
|
showOverline
|
||||||
<Text id="app.special.modals.account.failed" />
|
error={errors.current_password?.message}
|
||||||
</Overline>
|
/>
|
||||||
)}
|
{error && (
|
||||||
</form>
|
<Overline type="error" error={error}>
|
||||||
</Modal>
|
<Text id="app.special.modals.account.failed" />
|
||||||
);
|
</Overline>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,27 +1,31 @@
|
||||||
import { Text } from "preact-i18n";
|
|
||||||
import styles from "./UserPicker.module.scss";
|
import styles from "./UserPicker.module.scss";
|
||||||
import { useUsers } from "../../revoltjs/hooks";
|
import { Text } from "preact-i18n";
|
||||||
|
|
||||||
import Modal from "../../../components/ui/Modal";
|
import Modal from "../../../components/ui/Modal";
|
||||||
|
|
||||||
import { Friend } from "../../../pages/friends/Friend";
|
import { Friend } from "../../../pages/friends/Friend";
|
||||||
|
import { useUsers } from "../../revoltjs/hooks";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
users: string[];
|
users: string[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PendingRequests({ users: ids, onClose }: Props) {
|
export function PendingRequests({ users: ids, onClose }: Props) {
|
||||||
const users = useUsers(ids);
|
const users = useUsers(ids);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
visible={true}
|
visible={true}
|
||||||
title={<Text id="app.special.friends.pending" />}
|
title={<Text id="app.special.friends.pending" />}
|
||||||
onClose={onClose}>
|
onClose={onClose}>
|
||||||
<div className={styles.list}>
|
<div className={styles.list}>
|
||||||
{ users
|
{users
|
||||||
.filter(x => typeof x !== 'undefined')
|
.filter((x) => typeof x !== "undefined")
|
||||||
.map(x => <Friend user={x!} key={x!._id} />) }
|
.map((x) => (
|
||||||
</div>
|
<Friend user={x!} key={x!._id} />
|
||||||
</Modal>
|
))}
|
||||||
);
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,64 +1,68 @@
|
||||||
|
import { User, Users } from "revolt.js/dist/api/objects";
|
||||||
|
|
||||||
|
import styles from "./UserPicker.module.scss";
|
||||||
import { Text } from "preact-i18n";
|
import { Text } from "preact-i18n";
|
||||||
import { useState } from "preact/hooks";
|
import { useState } from "preact/hooks";
|
||||||
import styles from "./UserPicker.module.scss";
|
|
||||||
import { useUsers } from "../../revoltjs/hooks";
|
|
||||||
import Modal from "../../../components/ui/Modal";
|
|
||||||
import { User, Users } from "revolt.js/dist/api/objects";
|
|
||||||
import UserCheckbox from "../../../components/common/user/UserCheckbox";
|
import UserCheckbox from "../../../components/common/user/UserCheckbox";
|
||||||
|
import Modal from "../../../components/ui/Modal";
|
||||||
|
|
||||||
|
import { useUsers } from "../../revoltjs/hooks";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
omit?: string[];
|
omit?: string[];
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
callback: (users: string[]) => Promise<void>;
|
callback: (users: string[]) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserPicker(props: Props) {
|
export function UserPicker(props: Props) {
|
||||||
const [selected, setSelected] = useState<string[]>([]);
|
const [selected, setSelected] = useState<string[]>([]);
|
||||||
const omit = [...(props.omit || []), "00000000000000000000000000"];
|
const omit = [...(props.omit || []), "00000000000000000000000000"];
|
||||||
|
|
||||||
const users = useUsers();
|
const users = useUsers();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
visible={true}
|
visible={true}
|
||||||
title={<Text id="app.special.popovers.user_picker.select" />}
|
title={<Text id="app.special.popovers.user_picker.select" />}
|
||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
actions={[
|
actions={[
|
||||||
{
|
{
|
||||||
text: <Text id="app.special.modals.actions.ok" />,
|
text: <Text id="app.special.modals.actions.ok" />,
|
||||||
onClick: () => props.callback(selected).then(props.onClose)
|
onClick: () => props.callback(selected).then(props.onClose),
|
||||||
}
|
},
|
||||||
]}
|
]}>
|
||||||
>
|
<div className={styles.list}>
|
||||||
<div className={styles.list}>
|
{(
|
||||||
{(users.filter(
|
users.filter(
|
||||||
x =>
|
(x) =>
|
||||||
x &&
|
x &&
|
||||||
x.relationship === Users.Relationship.Friend &&
|
x.relationship === Users.Relationship.Friend &&
|
||||||
!omit.includes(x._id)
|
!omit.includes(x._id),
|
||||||
) as User[])
|
) as User[]
|
||||||
.map(x => {
|
)
|
||||||
return {
|
.map((x) => {
|
||||||
...x,
|
return {
|
||||||
selected: selected.includes(x._id)
|
...x,
|
||||||
};
|
selected: selected.includes(x._id),
|
||||||
})
|
};
|
||||||
.map(x => (
|
})
|
||||||
<UserCheckbox
|
.map((x) => (
|
||||||
user={x}
|
<UserCheckbox
|
||||||
checked={x.selected}
|
user={x}
|
||||||
onChange={v => {
|
checked={x.selected}
|
||||||
if (v) {
|
onChange={(v) => {
|
||||||
setSelected([...selected, x._id]);
|
if (v) {
|
||||||
} else {
|
setSelected([...selected, x._id]);
|
||||||
setSelected(
|
} else {
|
||||||
selected.filter(y => y !== x._id)
|
setSelected(
|
||||||
);
|
selected.filter((y) => y !== x._id),
|
||||||
}
|
);
|
||||||
}}
|
}
|
||||||
/>
|
}}
|
||||||
))}
|
/>
|
||||||
</div>
|
))}
|
||||||
</Modal>
|
</div>
|
||||||
);
|
</Modal>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,331 +1,362 @@
|
||||||
import { decodeTime } from "ulid";
|
import {
|
||||||
|
Envelope,
|
||||||
|
Edit,
|
||||||
|
UserPlus,
|
||||||
|
Shield,
|
||||||
|
Money,
|
||||||
|
} from "@styled-icons/boxicons-regular";
|
||||||
import { Link, useHistory } from "react-router-dom";
|
import { Link, useHistory } from "react-router-dom";
|
||||||
import { Localizer, Text } from "preact-i18n";
|
|
||||||
import styles from "./UserProfile.module.scss";
|
|
||||||
import Modal from "../../../components/ui/Modal";
|
|
||||||
import { Route } from "revolt.js/dist/api/routes";
|
|
||||||
import { Users } from "revolt.js/dist/api/objects";
|
import { Users } from "revolt.js/dist/api/objects";
|
||||||
import { useIntermediate } from "../Intermediate";
|
|
||||||
import Preloader from "../../../components/ui/Preloader";
|
|
||||||
import Tooltip from '../../../components/common/Tooltip';
|
|
||||||
import IconButton from "../../../components/ui/IconButton";
|
|
||||||
import Markdown from '../../../components/markdown/Markdown';
|
|
||||||
import { UserPermission } from "revolt.js/dist/api/permissions";
|
import { UserPermission } from "revolt.js/dist/api/permissions";
|
||||||
import UserIcon from '../../../components/common/user/UserIcon';
|
import { Route } from "revolt.js/dist/api/routes";
|
||||||
import ChannelIcon from '../../../components/common/ChannelIcon';
|
import { decodeTime } from "ulid";
|
||||||
import UserStatus from '../../../components/common/user/UserStatus';
|
|
||||||
import { Envelope, Edit, UserPlus, Shield, Money } from "@styled-icons/boxicons-regular";
|
import styles from "./UserProfile.module.scss";
|
||||||
|
import { Localizer, Text } from "preact-i18n";
|
||||||
import { useContext, useEffect, useLayoutEffect, useState } from "preact/hooks";
|
import { useContext, useEffect, useLayoutEffect, useState } from "preact/hooks";
|
||||||
import { AppContext, ClientStatus, StatusContext } from "../../revoltjs/RevoltClient";
|
|
||||||
import { useChannels, useForceUpdate, useUserPermission, useUsers } from "../../revoltjs/hooks";
|
import ChannelIcon from "../../../components/common/ChannelIcon";
|
||||||
|
import Tooltip from "../../../components/common/Tooltip";
|
||||||
|
import UserIcon from "../../../components/common/user/UserIcon";
|
||||||
|
import UserStatus from "../../../components/common/user/UserStatus";
|
||||||
|
import IconButton from "../../../components/ui/IconButton";
|
||||||
|
import Modal from "../../../components/ui/Modal";
|
||||||
|
import Preloader from "../../../components/ui/Preloader";
|
||||||
|
|
||||||
|
import Markdown from "../../../components/markdown/Markdown";
|
||||||
|
import {
|
||||||
|
AppContext,
|
||||||
|
ClientStatus,
|
||||||
|
StatusContext,
|
||||||
|
} from "../../revoltjs/RevoltClient";
|
||||||
|
import {
|
||||||
|
useChannels,
|
||||||
|
useForceUpdate,
|
||||||
|
useUserPermission,
|
||||||
|
useUsers,
|
||||||
|
} from "../../revoltjs/hooks";
|
||||||
|
import { useIntermediate } from "../Intermediate";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
user_id: string;
|
user_id: string;
|
||||||
dummy?: boolean;
|
dummy?: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
dummyProfile?: Users.Profile;
|
dummyProfile?: Users.Profile;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Badges {
|
enum Badges {
|
||||||
Developer = 1,
|
Developer = 1,
|
||||||
Translator = 2,
|
Translator = 2,
|
||||||
Supporter = 4,
|
Supporter = 4,
|
||||||
ResponsibleDisclosure = 8,
|
ResponsibleDisclosure = 8,
|
||||||
EarlyAdopter = 256
|
EarlyAdopter = 256,
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserProfile({ user_id, onClose, dummy, dummyProfile }: Props) {
|
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>(
|
const [profile, setProfile] = useState<undefined | null | Users.Profile>(
|
||||||
undefined
|
undefined,
|
||||||
);
|
);
|
||||||
const [mutual, setMutual] = useState<
|
const [mutual, setMutual] = useState<
|
||||||
undefined | null | Route<"GET", "/users/id/mutual">["response"]
|
undefined | null | Route<"GET", "/users/id/mutual">["response"]
|
||||||
>(undefined);
|
>(undefined);
|
||||||
|
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const client = useContext(AppContext);
|
const client = useContext(AppContext);
|
||||||
const status = useContext(StatusContext);
|
const status = useContext(StatusContext);
|
||||||
const [tab, setTab] = useState("profile");
|
const [tab, setTab] = useState("profile");
|
||||||
|
|
||||||
const ctx = useForceUpdate();
|
const ctx = useForceUpdate();
|
||||||
const all_users = useUsers(undefined, ctx);
|
const all_users = useUsers(undefined, ctx);
|
||||||
const channels = useChannels(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;
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
useEffect(onClose, []);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const permissions = useUserPermission(user!._id, 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;
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
if (!user) {
|
||||||
if (!user_id) return;
|
useEffect(onClose, []);
|
||||||
if (typeof profile !== 'undefined') setProfile(undefined);
|
return null;
|
||||||
if (typeof mutual !== 'undefined') setMutual(undefined);
|
}
|
||||||
}, [user_id]);
|
|
||||||
|
|
||||||
if (dummy) {
|
const permissions = useUserPermission(user!._id, ctx);
|
||||||
useLayoutEffect(() => {
|
|
||||||
setProfile(dummyProfile);
|
|
||||||
}, [dummyProfile]);
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (dummy) return;
|
if (!user_id) return;
|
||||||
if (
|
if (typeof profile !== "undefined") setProfile(undefined);
|
||||||
status === ClientStatus.ONLINE &&
|
if (typeof mutual !== "undefined") setMutual(undefined);
|
||||||
typeof mutual === "undefined"
|
}, [user_id]);
|
||||||
) {
|
|
||||||
setMutual(null);
|
|
||||||
client.users
|
|
||||||
.fetchMutual(user_id)
|
|
||||||
.then(data => setMutual(data));
|
|
||||||
}
|
|
||||||
}, [mutual, status]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (dummy) {
|
||||||
if (dummy) return;
|
useLayoutEffect(() => {
|
||||||
if (
|
setProfile(dummyProfile);
|
||||||
status === ClientStatus.ONLINE &&
|
}, [dummyProfile]);
|
||||||
typeof profile === "undefined"
|
}
|
||||||
) {
|
|
||||||
setProfile(null);
|
|
||||||
|
|
||||||
if (permissions & UserPermission.ViewProfile) {
|
useEffect(() => {
|
||||||
client.users
|
if (dummy) return;
|
||||||
.fetchProfile(user_id)
|
if (status === ClientStatus.ONLINE && typeof mutual === "undefined") {
|
||||||
.then(data => setProfile(data))
|
setMutual(null);
|
||||||
.catch(() => {});
|
client.users.fetchMutual(user_id).then((data) => setMutual(data));
|
||||||
}
|
}
|
||||||
}
|
}, [mutual, status]);
|
||||||
}, [profile, status]);
|
|
||||||
|
|
||||||
const mutualGroups = channels.filter(
|
useEffect(() => {
|
||||||
channel =>
|
if (dummy) return;
|
||||||
channel?.channel_type === "Group" &&
|
if (status === ClientStatus.ONLINE && typeof profile === "undefined") {
|
||||||
channel.recipients.includes(user_id)
|
setProfile(null);
|
||||||
);
|
|
||||||
|
|
||||||
const backgroundURL = profile && client.users.getBackgroundURL(profile, { width: 1000 }, true);
|
if (permissions & UserPermission.ViewProfile) {
|
||||||
const badges = (user.badges ?? 0) | (decodeTime(user._id) < 1623751765790 ? Badges.EarlyAdopter : 0);
|
client.users
|
||||||
|
.fetchProfile(user_id)
|
||||||
|
.then((data) => setProfile(data))
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [profile, status]);
|
||||||
|
|
||||||
return (
|
const mutualGroups = channels.filter(
|
||||||
<Modal visible
|
(channel) =>
|
||||||
border={dummy}
|
channel?.channel_type === "Group" &&
|
||||||
padding={false}
|
channel.recipients.includes(user_id),
|
||||||
onClose={onClose}
|
);
|
||||||
dontModal={dummy}>
|
|
||||||
<div
|
const backgroundURL =
|
||||||
className={styles.header}
|
profile &&
|
||||||
data-force={
|
client.users.getBackgroundURL(profile, { width: 1000 }, true);
|
||||||
profile?.background
|
const badges =
|
||||||
? "light"
|
(user.badges ?? 0) |
|
||||||
: undefined
|
(decodeTime(user._id) < 1623751765790 ? Badges.EarlyAdopter : 0);
|
||||||
}
|
|
||||||
style={{
|
return (
|
||||||
backgroundImage: backgroundURL && `linear-gradient( rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.7) ), url('${backgroundURL}')`
|
<Modal
|
||||||
}}
|
visible
|
||||||
>
|
border={dummy}
|
||||||
<div className={styles.profile}>
|
padding={false}
|
||||||
<UserIcon size={80} target={user} status />
|
onClose={onClose}
|
||||||
<div className={styles.details}>
|
dontModal={dummy}>
|
||||||
<Localizer>
|
<div
|
||||||
<span
|
className={styles.header}
|
||||||
className={styles.username}
|
data-force={profile?.background ? "light" : undefined}
|
||||||
onClick={() => writeClipboard(user.username)}>
|
style={{
|
||||||
@{user.username}
|
backgroundImage:
|
||||||
</span>
|
backgroundURL &&
|
||||||
</Localizer>
|
`linear-gradient( rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.7) ), url('${backgroundURL}')`,
|
||||||
{user.status?.text && (
|
}}>
|
||||||
<span className={styles.status}>
|
<div className={styles.profile}>
|
||||||
<UserStatus user={user} />
|
<UserIcon size={80} target={user} status />
|
||||||
</span>
|
<div className={styles.details}>
|
||||||
)}
|
<Localizer>
|
||||||
</div>
|
<span
|
||||||
{user.relationship === Users.Relationship.Friend && (
|
className={styles.username}
|
||||||
<Localizer>
|
onClick={() => writeClipboard(user.username)}>
|
||||||
<Tooltip
|
@{user.username}
|
||||||
content={
|
</span>
|
||||||
<Text id="app.context_menu.message_user" />
|
</Localizer>
|
||||||
}
|
{user.status?.text && (
|
||||||
>
|
<span className={styles.status}>
|
||||||
<IconButton
|
<UserStatus user={user} />
|
||||||
onClick={() => {
|
</span>
|
||||||
onClose();
|
)}
|
||||||
history.push(`/open/${user_id}`);
|
</div>
|
||||||
}}>
|
{user.relationship === Users.Relationship.Friend && (
|
||||||
<Envelope size={30} />
|
<Localizer>
|
||||||
</IconButton>
|
<Tooltip
|
||||||
</Tooltip>
|
content={
|
||||||
</Localizer>
|
<Text id="app.context_menu.message_user" />
|
||||||
)}
|
}>
|
||||||
{user.relationship === Users.Relationship.User && (
|
<IconButton
|
||||||
<IconButton
|
onClick={() => {
|
||||||
onClick={() => {
|
onClose();
|
||||||
onClose();
|
history.push(`/open/${user_id}`);
|
||||||
if (dummy) return;
|
}}>
|
||||||
history.push(`/settings/profile`);
|
<Envelope size={30} />
|
||||||
}}>
|
</IconButton>
|
||||||
<Edit size={28} />
|
</Tooltip>
|
||||||
</IconButton>
|
</Localizer>
|
||||||
)}
|
)}
|
||||||
{(user.relationship === Users.Relationship.Incoming ||
|
{user.relationship === Users.Relationship.User && (
|
||||||
user.relationship === Users.Relationship.None) && (
|
<IconButton
|
||||||
<IconButton onClick={() => client.users.addFriend(user.username)}>
|
onClick={() => {
|
||||||
<UserPlus size={28} />
|
onClose();
|
||||||
</IconButton>
|
if (dummy) return;
|
||||||
)}
|
history.push(`/settings/profile`);
|
||||||
</div>
|
}}>
|
||||||
<div className={styles.tabs}>
|
<Edit size={28} />
|
||||||
<div
|
</IconButton>
|
||||||
data-active={tab === "profile"}
|
)}
|
||||||
onClick={() => setTab("profile")}
|
{(user.relationship === Users.Relationship.Incoming ||
|
||||||
>
|
user.relationship === Users.Relationship.None) && (
|
||||||
<Text id="app.special.popovers.user_profile.profile" />
|
<IconButton
|
||||||
</div>
|
onClick={() =>
|
||||||
{ user.relationship !== Users.Relationship.User &&
|
client.users.addFriend(user.username)
|
||||||
<>
|
}>
|
||||||
<div
|
<UserPlus size={28} />
|
||||||
data-active={tab === "friends"}
|
</IconButton>
|
||||||
onClick={() => setTab("friends")}
|
)}
|
||||||
>
|
</div>
|
||||||
<Text id="app.special.popovers.user_profile.mutual_friends" />
|
<div className={styles.tabs}>
|
||||||
</div>
|
<div
|
||||||
<div
|
data-active={tab === "profile"}
|
||||||
data-active={tab === "groups"}
|
onClick={() => setTab("profile")}>
|
||||||
onClick={() => setTab("groups")}
|
<Text id="app.special.popovers.user_profile.profile" />
|
||||||
>
|
</div>
|
||||||
<Text id="app.special.popovers.user_profile.mutual_groups" />
|
{user.relationship !== Users.Relationship.User && (
|
||||||
</div>
|
<>
|
||||||
</>
|
<div
|
||||||
}
|
data-active={tab === "friends"}
|
||||||
</div>
|
onClick={() => setTab("friends")}>
|
||||||
</div>
|
<Text id="app.special.popovers.user_profile.mutual_friends" />
|
||||||
<div className={styles.content}>
|
</div>
|
||||||
{tab === "profile" &&
|
<div
|
||||||
<div>
|
data-active={tab === "groups"}
|
||||||
{ !(profile?.content || (badges > 0)) &&
|
onClick={() => setTab("groups")}>
|
||||||
<div className={styles.empty}><Text id="app.special.popovers.user_profile.empty" /></div> }
|
<Text id="app.special.popovers.user_profile.mutual_groups" />
|
||||||
{ (badges > 0) && <div className={styles.category}><Text id="app.special.popovers.user_profile.sub.badges" /></div> }
|
</div>
|
||||||
{ (badges > 0) && (
|
</>
|
||||||
<div className={styles.badges}>
|
)}
|
||||||
<Localizer>
|
</div>
|
||||||
{badges & Badges.Developer ? (
|
</div>
|
||||||
<Tooltip
|
<div className={styles.content}>
|
||||||
content={
|
{tab === "profile" && (
|
||||||
<Text id="app.navigation.tabs.dev" />
|
<div>
|
||||||
}
|
{!(profile?.content || badges > 0) && (
|
||||||
>
|
<div className={styles.empty}>
|
||||||
<img src="/assets/badges/developer.svg" />
|
<Text id="app.special.popovers.user_profile.empty" />
|
||||||
</Tooltip>
|
</div>
|
||||||
) : (
|
)}
|
||||||
<></>
|
{badges > 0 && (
|
||||||
)}
|
<div className={styles.category}>
|
||||||
{badges & Badges.Translator ? (
|
<Text id="app.special.popovers.user_profile.sub.badges" />
|
||||||
<Tooltip
|
</div>
|
||||||
content={
|
)}
|
||||||
<Text id="app.special.popovers.user_profile.badges.translator" />
|
{badges > 0 && (
|
||||||
}
|
<div className={styles.badges}>
|
||||||
>
|
<Localizer>
|
||||||
<img src="/assets/badges/translator.svg" />
|
{badges & Badges.Developer ? (
|
||||||
</Tooltip>
|
<Tooltip
|
||||||
) : (
|
content={
|
||||||
<></>
|
<Text id="app.navigation.tabs.dev" />
|
||||||
)}
|
}>
|
||||||
{badges & Badges.EarlyAdopter ? (
|
<img src="/assets/badges/developer.svg" />
|
||||||
<Tooltip
|
</Tooltip>
|
||||||
content={
|
) : (
|
||||||
<Text id="app.special.popovers.user_profile.badges.early_adopter" />
|
<></>
|
||||||
}
|
)}
|
||||||
>
|
{badges & Badges.Translator ? (
|
||||||
<img src="/assets/badges/early_adopter.svg" />
|
<Tooltip
|
||||||
</Tooltip>
|
content={
|
||||||
) : (
|
<Text id="app.special.popovers.user_profile.badges.translator" />
|
||||||
<></>
|
}>
|
||||||
)}
|
<img src="/assets/badges/translator.svg" />
|
||||||
{badges & Badges.Supporter ? (
|
</Tooltip>
|
||||||
<Tooltip
|
) : (
|
||||||
content={
|
<></>
|
||||||
<Text id="app.special.popovers.user_profile.badges.supporter" />
|
)}
|
||||||
}
|
{badges & Badges.EarlyAdopter ? (
|
||||||
>
|
<Tooltip
|
||||||
<Money size={32} color="#efab44" />
|
content={
|
||||||
</Tooltip>
|
<Text id="app.special.popovers.user_profile.badges.early_adopter" />
|
||||||
) : (
|
}>
|
||||||
<></>
|
<img src="/assets/badges/early_adopter.svg" />
|
||||||
)}
|
</Tooltip>
|
||||||
{badges & Badges.ResponsibleDisclosure ? (
|
) : (
|
||||||
<Tooltip
|
<></>
|
||||||
content={
|
)}
|
||||||
<Text id="app.special.popovers.user_profile.badges.responsible_disclosure" />
|
{badges & Badges.Supporter ? (
|
||||||
}
|
<Tooltip
|
||||||
>
|
content={
|
||||||
<Shield size={32} color="gray" />
|
<Text id="app.special.popovers.user_profile.badges.supporter" />
|
||||||
</Tooltip>
|
}>
|
||||||
) : (
|
<Money size={32} color="#efab44" />
|
||||||
<></>
|
</Tooltip>
|
||||||
)}
|
) : (
|
||||||
</Localizer>
|
<></>
|
||||||
</div>
|
)}
|
||||||
)}
|
{badges & Badges.ResponsibleDisclosure ? (
|
||||||
{ profile?.content && <div className={styles.category}><Text id="app.special.popovers.user_profile.sub.information" /></div> }
|
<Tooltip
|
||||||
<Markdown content={profile?.content} />
|
content={
|
||||||
{/*<div className={styles.category}><Text id="app.special.popovers.user_profile.sub.connections" /></div>*/}
|
<Text id="app.special.popovers.user_profile.badges.responsible_disclosure" />
|
||||||
</div>}
|
}>
|
||||||
{tab === "friends" &&
|
<Shield size={32} color="gray" />
|
||||||
(users ? (
|
</Tooltip>
|
||||||
<div className={styles.entries}>
|
) : (
|
||||||
{users.length === 0 ? (
|
<></>
|
||||||
<div className={styles.empty}>
|
)}
|
||||||
<Text id="app.special.popovers.user_profile.no_users" />
|
</Localizer>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
)}
|
||||||
users.map(
|
{profile?.content && (
|
||||||
x =>
|
<div className={styles.category}>
|
||||||
x && (
|
<Text id="app.special.popovers.user_profile.sub.information" />
|
||||||
<div onClick={() => openScreen({ id: 'profile', user_id: x._id })}
|
</div>
|
||||||
className={styles.entry}
|
)}
|
||||||
key={x._id}>
|
<Markdown content={profile?.content} />
|
||||||
<UserIcon size={32} target={x} />
|
{/*<div className={styles.category}><Text id="app.special.popovers.user_profile.sub.connections" /></div>*/}
|
||||||
<span>{x.username}</span>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)
|
{tab === "friends" &&
|
||||||
)
|
(users ? (
|
||||||
)}
|
<div className={styles.entries}>
|
||||||
</div>
|
{users.length === 0 ? (
|
||||||
) : (
|
<div className={styles.empty}>
|
||||||
<Preloader type="ring" />
|
<Text id="app.special.popovers.user_profile.no_users" />
|
||||||
))}
|
</div>
|
||||||
{tab === "groups" && (
|
) : (
|
||||||
<div className={styles.entries}>
|
users.map(
|
||||||
{mutualGroups.length === 0 ? (
|
(x) =>
|
||||||
<div className={styles.empty}>
|
x && (
|
||||||
<Text id="app.special.popovers.user_profile.no_groups" />
|
<div
|
||||||
</div>
|
onClick={() =>
|
||||||
) : (
|
openScreen({
|
||||||
mutualGroups.map(
|
id: "profile",
|
||||||
x =>
|
user_id: x._id,
|
||||||
x?.channel_type === "Group" && (
|
})
|
||||||
<Link to={`/channel/${x._id}`}>
|
}
|
||||||
<div
|
className={styles.entry}
|
||||||
className={styles.entry}
|
key={x._id}>
|
||||||
key={x._id}
|
<UserIcon
|
||||||
>
|
size={32}
|
||||||
<ChannelIcon target={x} size={32} />
|
target={x}
|
||||||
<span>{x.name}</span>
|
/>
|
||||||
</div>
|
<span>{x.username}</span>
|
||||||
</Link>
|
</div>
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (
|
||||||
</div>
|
<Preloader type="ring" />
|
||||||
</Modal>
|
))}
|
||||||
);
|
{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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,22 +1,23 @@
|
||||||
import { useContext } from "preact/hooks";
|
|
||||||
import { Redirect } from "react-router-dom";
|
import { Redirect } from "react-router-dom";
|
||||||
import { Children } from "../../types/Preact";
|
|
||||||
|
|
||||||
|
import { useContext } from "preact/hooks";
|
||||||
|
|
||||||
|
import { Children } from "../../types/Preact";
|
||||||
import { OperationsContext } from "./RevoltClient";
|
import { OperationsContext } from "./RevoltClient";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
auth?: boolean;
|
auth?: boolean;
|
||||||
children: Children;
|
children: Children;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CheckAuth = (props: Props) => {
|
export const CheckAuth = (props: Props) => {
|
||||||
const operations = useContext(OperationsContext);
|
const operations = useContext(OperationsContext);
|
||||||
|
|
||||||
if (props.auth && !operations.ready()) {
|
if (props.auth && !operations.ready()) {
|
||||||
return <Redirect to="/login" />;
|
return <Redirect to="/login" />;
|
||||||
} else if (!props.auth && operations.ready()) {
|
} else if (!props.auth && operations.ready()) {
|
||||||
return <Redirect to="/" />;
|
return <Redirect to="/" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{props.children}</>;
|
return <>{props.children}</>;
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,221 +1,292 @@
|
||||||
import { Text } from "preact-i18n";
|
|
||||||
import { takeError } from "./util";
|
|
||||||
import classNames from "classnames";
|
|
||||||
import { AppContext } from "./RevoltClient";
|
|
||||||
import styles from './FileUploads.module.scss';
|
|
||||||
import Axios, { AxiosRequestConfig } from "axios";
|
|
||||||
import { useContext, useEffect, useState } from "preact/hooks";
|
|
||||||
import Preloader from "../../components/ui/Preloader";
|
|
||||||
import { determineFileSize } from "../../lib/fileSize";
|
|
||||||
import IconButton from '../../components/ui/IconButton';
|
|
||||||
import { Plus, X, XCircle } from "@styled-icons/boxicons-regular";
|
import { Plus, X, XCircle } from "@styled-icons/boxicons-regular";
|
||||||
import { Pencil } from "@styled-icons/boxicons-solid";
|
import { Pencil } from "@styled-icons/boxicons-solid";
|
||||||
|
import Axios, { AxiosRequestConfig } from "axios";
|
||||||
|
|
||||||
|
import styles from "./FileUploads.module.scss";
|
||||||
|
import classNames from "classnames";
|
||||||
|
import { Text } from "preact-i18n";
|
||||||
|
import { useContext, useEffect, useState } from "preact/hooks";
|
||||||
|
|
||||||
|
import { determineFileSize } from "../../lib/fileSize";
|
||||||
|
|
||||||
|
import IconButton from "../../components/ui/IconButton";
|
||||||
|
import Preloader from "../../components/ui/Preloader";
|
||||||
|
|
||||||
import { useIntermediate } from "../intermediate/Intermediate";
|
import { useIntermediate } from "../intermediate/Intermediate";
|
||||||
|
import { AppContext } from "./RevoltClient";
|
||||||
|
import { takeError } from "./util";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
maxFileSize: number
|
maxFileSize: number;
|
||||||
remove: () => Promise<void>
|
remove: () => Promise<void>;
|
||||||
fileType: 'backgrounds' | 'icons' | 'avatars' | 'attachments' | 'banners'
|
fileType: "backgrounds" | "icons" | "avatars" | "attachments" | "banners";
|
||||||
} & (
|
} & (
|
||||||
{ behaviour: 'ask', onChange: (file: File) => void } |
|
| { behaviour: "ask"; onChange: (file: File) => void }
|
||||||
{ behaviour: 'upload', onUpload: (id: string) => Promise<void> } |
|
| { behaviour: "upload"; onUpload: (id: string) => Promise<void> }
|
||||||
{ behaviour: 'multi', onChange: (files: File[]) => void, append?: (files: File[]) => void }
|
| {
|
||||||
) & (
|
behaviour: "multi";
|
||||||
{ style: 'icon' | 'banner', defaultPreview?: string, previewURL?: string, width?: number, height?: number } |
|
onChange: (files: File[]) => void;
|
||||||
{ style: 'attachment', attached: boolean, uploading: boolean, cancel: () => void, size?: number }
|
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;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
export async function uploadFile(autumnURL: string, tag: string, file: File, config?: AxiosRequestConfig) {
|
export async function uploadFile(
|
||||||
const formData = new FormData();
|
autumnURL: string,
|
||||||
formData.append("file", file);
|
tag: string,
|
||||||
|
file: File,
|
||||||
const res = await Axios.post(autumnURL + "/" + tag, formData, {
|
config?: AxiosRequestConfig,
|
||||||
headers: {
|
) {
|
||||||
"Content-Type": "multipart/form-data"
|
const formData = new FormData();
|
||||||
},
|
formData.append("file", file);
|
||||||
...config
|
|
||||||
});
|
|
||||||
|
|
||||||
return res.data.id;
|
const res = await Axios.post(autumnURL + "/" + tag, formData, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
|
...config,
|
||||||
|
});
|
||||||
|
|
||||||
|
return res.data.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function grabFiles(maxFileSize: number, cb: (files: File[]) => void, tooLarge: () => void, multiple?: boolean) {
|
export function grabFiles(
|
||||||
const input = document.createElement("input");
|
maxFileSize: number,
|
||||||
input.type = "file";
|
cb: (files: File[]) => void,
|
||||||
input.multiple = multiple ?? false;
|
tooLarge: () => void,
|
||||||
|
multiple?: boolean,
|
||||||
|
) {
|
||||||
|
const input = document.createElement("input");
|
||||||
|
input.type = "file";
|
||||||
|
input.multiple = multiple ?? false;
|
||||||
|
|
||||||
input.onchange = async (e) => {
|
input.onchange = async (e) => {
|
||||||
const files = (e.currentTarget as HTMLInputElement)?.files;
|
const files = (e.currentTarget as HTMLInputElement)?.files;
|
||||||
if (!files) return;
|
if (!files) return;
|
||||||
for (let file of files) {
|
for (let file of files) {
|
||||||
if (file.size > maxFileSize) {
|
if (file.size > maxFileSize) {
|
||||||
return tooLarge();
|
return tooLarge();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cb(Array.from(files));
|
cb(Array.from(files));
|
||||||
};
|
};
|
||||||
|
|
||||||
input.click();
|
input.click();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FileUploader(props: Props) {
|
export function FileUploader(props: Props) {
|
||||||
const { fileType, maxFileSize, remove } = props;
|
const { fileType, maxFileSize, remove } = props;
|
||||||
const { openScreen } = useIntermediate();
|
const { openScreen } = useIntermediate();
|
||||||
const client = useContext(AppContext);
|
const client = useContext(AppContext);
|
||||||
|
|
||||||
const [ uploading, setUploading ] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
function onClick() {
|
function onClick() {
|
||||||
if (uploading) return;
|
if (uploading) return;
|
||||||
|
|
||||||
grabFiles(maxFileSize, async files => {
|
grabFiles(
|
||||||
setUploading(true);
|
maxFileSize,
|
||||||
|
async (files) => {
|
||||||
|
setUploading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (props.behaviour === 'multi') {
|
if (props.behaviour === "multi") {
|
||||||
props.onChange(files);
|
props.onChange(files);
|
||||||
} else if (props.behaviour === 'ask') {
|
} else if (props.behaviour === "ask") {
|
||||||
props.onChange(files[0]);
|
props.onChange(files[0]);
|
||||||
} else {
|
} else {
|
||||||
await props.onUpload(await uploadFile(client.configuration!.features.autumn.url, fileType, files[0]));
|
await props.onUpload(
|
||||||
}
|
await uploadFile(
|
||||||
} catch (err) {
|
client.configuration!.features.autumn.url,
|
||||||
return openScreen({ id: "error", error: takeError(err) });
|
fileType,
|
||||||
} finally {
|
files[0],
|
||||||
setUploading(false);
|
),
|
||||||
}
|
);
|
||||||
}, () =>
|
}
|
||||||
openScreen({ id: "error", error: "FileTooLarge" }),
|
} catch (err) {
|
||||||
props.behaviour === 'multi');
|
return openScreen({ id: "error", error: takeError(err) });
|
||||||
}
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => openScreen({ id: "error", error: "FileTooLarge" }),
|
||||||
|
props.behaviour === "multi",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function removeOrUpload() {
|
function removeOrUpload() {
|
||||||
if (uploading) return;
|
if (uploading) return;
|
||||||
|
|
||||||
if (props.style === 'attachment') {
|
if (props.style === "attachment") {
|
||||||
if (props.attached) {
|
if (props.attached) {
|
||||||
props.remove();
|
props.remove();
|
||||||
} else {
|
} else {
|
||||||
onClick();
|
onClick();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (props.previewURL) {
|
if (props.previewURL) {
|
||||||
props.remove();
|
props.remove();
|
||||||
} else {
|
} else {
|
||||||
onClick();
|
onClick();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.behaviour === 'multi' && props.append) {
|
if (props.behaviour === "multi" && props.append) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// File pasting.
|
// File pasting.
|
||||||
function paste(e: ClipboardEvent) {
|
function paste(e: ClipboardEvent) {
|
||||||
const items = e.clipboardData?.items;
|
const items = e.clipboardData?.items;
|
||||||
if (typeof items === "undefined") return;
|
if (typeof items === "undefined") return;
|
||||||
if (props.behaviour !== 'multi' || !props.append) return;
|
if (props.behaviour !== "multi" || !props.append) return;
|
||||||
|
|
||||||
let files = [];
|
let files = [];
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (!item.type.startsWith("text/")) {
|
if (!item.type.startsWith("text/")) {
|
||||||
const blob = item.getAsFile();
|
const blob = item.getAsFile();
|
||||||
if (blob) {
|
if (blob) {
|
||||||
if (blob.size > props.maxFileSize) {
|
if (blob.size > props.maxFileSize) {
|
||||||
openScreen({ id: 'error', error: 'FileTooLarge' });
|
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.
|
// Let the browser know we can drop files.
|
||||||
function dragover(e: DragEvent) {
|
function dragover(e: DragEvent) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
|
if (e.dataTransfer) e.dataTransfer.dropEffect = "copy";
|
||||||
}
|
}
|
||||||
|
|
||||||
// File dropping.
|
// File dropping.
|
||||||
function drop(e: DragEvent) {
|
function drop(e: DragEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (props.behaviour !== 'multi' || !props.append) return;
|
if (props.behaviour !== "multi" || !props.append) return;
|
||||||
|
|
||||||
const dropped = e.dataTransfer?.files;
|
const dropped = e.dataTransfer?.files;
|
||||||
if (dropped) {
|
if (dropped) {
|
||||||
let files = [];
|
let files = [];
|
||||||
for (const item of dropped) {
|
for (const item of dropped) {
|
||||||
if (item.size > props.maxFileSize) {
|
if (item.size > props.maxFileSize) {
|
||||||
openScreen({ id: 'error', error: 'FileTooLarge' });
|
openScreen({ id: "error", error: "FileTooLarge" });
|
||||||
}
|
}
|
||||||
|
|
||||||
files.push(item);
|
files.push(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
props.append(files);
|
props.append(files);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener("paste", paste);
|
document.addEventListener("paste", paste);
|
||||||
document.addEventListener("dragover", dragover);
|
document.addEventListener("dragover", dragover);
|
||||||
document.addEventListener("drop", drop);
|
document.addEventListener("drop", drop);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener("paste", paste);
|
document.removeEventListener("paste", paste);
|
||||||
document.removeEventListener("dragover", dragover);
|
document.removeEventListener("dragover", dragover);
|
||||||
document.removeEventListener("drop", drop);
|
document.removeEventListener("drop", drop);
|
||||||
};
|
};
|
||||||
}, [ props.append ]);
|
}, [props.append]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.style === 'icon' || props.style === 'banner') {
|
if (props.style === "icon" || props.style === "banner") {
|
||||||
const { style, previewURL, defaultPreview, width, height } = props;
|
const { style, previewURL, defaultPreview, width, height } = props;
|
||||||
return (
|
return (
|
||||||
<div className={classNames(styles.uploader,
|
<div
|
||||||
{ [styles.icon]: style === 'icon',
|
className={classNames(styles.uploader, {
|
||||||
[styles.banner]: style === 'banner' })}
|
[styles.icon]: style === "icon",
|
||||||
data-uploading={uploading}>
|
[styles.banner]: style === "banner",
|
||||||
<div className={styles.image}
|
})}
|
||||||
style={{ backgroundImage:
|
data-uploading={uploading}>
|
||||||
style === 'icon' ? `url('${previewURL ?? defaultPreview}')` :
|
<div
|
||||||
(previewURL ? `linear-gradient( rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5) ), url('${previewURL}')` : 'black'),
|
className={styles.image}
|
||||||
width, height
|
style={{
|
||||||
}}
|
backgroundImage:
|
||||||
onClick={onClick}>
|
style === "icon"
|
||||||
{ uploading ?
|
? `url('${previewURL ?? defaultPreview}')`
|
||||||
<div className={styles.uploading}>
|
: previewURL
|
||||||
<Preloader type="ring" />
|
? `linear-gradient( rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5) ), url('${previewURL}')`
|
||||||
</div> :
|
: "black",
|
||||||
<div className={styles.edit}>
|
width,
|
||||||
<Pencil size={30} />
|
height,
|
||||||
</div> }
|
}}
|
||||||
</div>
|
onClick={onClick}>
|
||||||
<div className={styles.modify}>
|
{uploading ? (
|
||||||
<span onClick={removeOrUpload}>{
|
<div className={styles.uploading}>
|
||||||
uploading ? <Text id="app.main.channel.uploading_file" /> :
|
<Preloader type="ring" />
|
||||||
props.previewURL ? <Text id="app.settings.actions.remove" /> :
|
</div>
|
||||||
<Text id="app.settings.actions.upload" /> }</span>
|
) : (
|
||||||
<span className={styles.small}><Text id="app.settings.actions.max_filesize" fields={{ filesize: determineFileSize(maxFileSize) }} /></span>
|
<div className={styles.edit}>
|
||||||
</div>
|
<Pencil size={30} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)}
|
||||||
} else if (props.style === 'attachment') {
|
</div>
|
||||||
const { attached, uploading, cancel, size } = props;
|
<div className={styles.modify}>
|
||||||
return (
|
<span onClick={removeOrUpload}>
|
||||||
<IconButton
|
{uploading ? (
|
||||||
onClick={() => {
|
<Text id="app.main.channel.uploading_file" />
|
||||||
if (uploading) return cancel();
|
) : props.previewURL ? (
|
||||||
if (attached) return remove();
|
<Text id="app.settings.actions.remove" />
|
||||||
onClick();
|
) : (
|
||||||
}}>
|
<Text id="app.settings.actions.upload" />
|
||||||
{ uploading ? <XCircle size={size} /> : attached ? <X size={size} /> : <Plus size={size} />}
|
)}
|
||||||
</IconButton>
|
</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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,247 +1,286 @@
|
||||||
|
import { Route, Switch, useHistory, useParams } from "react-router-dom";
|
||||||
|
import { Message, SYSTEM_USER_ID, User } from "revolt.js";
|
||||||
|
import { Users } from "revolt.js/dist/api/objects";
|
||||||
import { decodeTime } from "ulid";
|
import { decodeTime } from "ulid";
|
||||||
|
|
||||||
|
import { useContext, useEffect } from "preact/hooks";
|
||||||
|
|
||||||
|
import { useTranslation } from "../../lib/i18n";
|
||||||
|
|
||||||
|
import { connectState } from "../../redux/connector";
|
||||||
|
import {
|
||||||
|
getNotificationState,
|
||||||
|
Notifications,
|
||||||
|
shouldNotify,
|
||||||
|
} from "../../redux/reducers/notifications";
|
||||||
|
import { NotificationOptions } from "../../redux/reducers/settings";
|
||||||
|
|
||||||
import { SoundContext } from "../Settings";
|
import { SoundContext } from "../Settings";
|
||||||
import { AppContext } from "./RevoltClient";
|
import { AppContext } from "./RevoltClient";
|
||||||
import { useTranslation } from "../../lib/i18n";
|
|
||||||
import { Users } from "revolt.js/dist/api/objects";
|
|
||||||
import { useContext, useEffect } from "preact/hooks";
|
|
||||||
import { connectState } from "../../redux/connector";
|
|
||||||
import { Message, SYSTEM_USER_ID, User } from "revolt.js";
|
|
||||||
import { NotificationOptions } from "../../redux/reducers/settings";
|
|
||||||
import { Route, Switch, useHistory, useParams } from "react-router-dom";
|
|
||||||
import { getNotificationState, Notifications, shouldNotify } from "../../redux/reducers/notifications";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
options?: NotificationOptions;
|
options?: NotificationOptions;
|
||||||
notifs: Notifications;
|
notifs: Notifications;
|
||||||
}
|
}
|
||||||
|
|
||||||
const notifications: { [key: string]: Notification } = {};
|
const notifications: { [key: string]: Notification } = {};
|
||||||
|
|
||||||
async function createNotification(title: string, options: globalThis.NotificationOptions) {
|
async function createNotification(
|
||||||
try {
|
title: string,
|
||||||
return new Notification(title, options);
|
options: globalThis.NotificationOptions,
|
||||||
} catch (err) {
|
) {
|
||||||
let sw = await navigator.serviceWorker.getRegistration();
|
try {
|
||||||
sw?.showNotification(title, options);
|
return new Notification(title, options);
|
||||||
}
|
} catch (err) {
|
||||||
|
let sw = await navigator.serviceWorker.getRegistration();
|
||||||
|
sw?.showNotification(title, options);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function Notifier({ options, notifs }: Props) {
|
function Notifier({ options, notifs }: Props) {
|
||||||
const translate = useTranslation();
|
const translate = useTranslation();
|
||||||
const showNotification = options?.desktopEnabled ?? false;
|
const showNotification = options?.desktopEnabled ?? false;
|
||||||
|
|
||||||
const client = useContext(AppContext);
|
const client = useContext(AppContext);
|
||||||
const { guild: guild_id, channel: channel_id } = useParams<{
|
const { guild: guild_id, channel: channel_id } = useParams<{
|
||||||
guild: string;
|
guild: string;
|
||||||
channel: string;
|
channel: string;
|
||||||
}>();
|
}>();
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const playSound = useContext(SoundContext);
|
const playSound = useContext(SoundContext);
|
||||||
|
|
||||||
async function message(msg: Message) {
|
async function message(msg: Message) {
|
||||||
if (msg.author === client.user!._id) return;
|
if (msg.author === client.user!._id) return;
|
||||||
if (msg.channel === channel_id && document.hasFocus()) return;
|
if (msg.channel === channel_id && document.hasFocus()) return;
|
||||||
if (client.user!.status?.presence === Users.Presence.Busy) return;
|
if (client.user!.status?.presence === Users.Presence.Busy) return;
|
||||||
|
|
||||||
const channel = client.channels.get(msg.channel);
|
const channel = client.channels.get(msg.channel);
|
||||||
const author = client.users.get(msg.author);
|
const author = client.users.get(msg.author);
|
||||||
if (!channel) return;
|
if (!channel) return;
|
||||||
if (author?.relationship === Users.Relationship.Blocked) return;
|
if (author?.relationship === Users.Relationship.Blocked) return;
|
||||||
|
|
||||||
const notifState = getNotificationState(notifs, channel);
|
const notifState = getNotificationState(notifs, channel);
|
||||||
if (!shouldNotify(notifState, msg, client.user!._id)) return;
|
if (!shouldNotify(notifState, msg, client.user!._id)) return;
|
||||||
|
|
||||||
playSound('message');
|
playSound("message");
|
||||||
if (!showNotification) return;
|
if (!showNotification) return;
|
||||||
|
|
||||||
let title;
|
let title;
|
||||||
switch (channel.channel_type) {
|
switch (channel.channel_type) {
|
||||||
case "SavedMessages":
|
case "SavedMessages":
|
||||||
return;
|
return;
|
||||||
case "DirectMessage":
|
case "DirectMessage":
|
||||||
title = `@${author?.username}`;
|
title = `@${author?.username}`;
|
||||||
break;
|
break;
|
||||||
case "Group":
|
case "Group":
|
||||||
if (author?._id === SYSTEM_USER_ID) {
|
if (author?._id === SYSTEM_USER_ID) {
|
||||||
title = channel.name;
|
title = channel.name;
|
||||||
} else {
|
} else {
|
||||||
title = `@${author?.username} - ${channel.name}`;
|
title = `@${author?.username} - ${channel.name}`;
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "TextChannel":
|
case "TextChannel":
|
||||||
const server = client.servers.get(channel.server);
|
const server = client.servers.get(channel.server);
|
||||||
title = `@${author?.username} (#${channel.name}, ${server?.name})`;
|
title = `@${author?.username} (#${channel.name}, ${server?.name})`;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
title = msg.channel;
|
title = msg.channel;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let image;
|
let image;
|
||||||
if (msg.attachments) {
|
if (msg.attachments) {
|
||||||
let imageAttachment = msg.attachments.find(x => x.metadata.type === 'Image');
|
let imageAttachment = msg.attachments.find(
|
||||||
if (imageAttachment) {
|
(x) => x.metadata.type === "Image",
|
||||||
image = client.generateFileURL(imageAttachment, { max_side: 720 });
|
);
|
||||||
}
|
if (imageAttachment) {
|
||||||
}
|
image = client.generateFileURL(imageAttachment, {
|
||||||
|
max_side: 720,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let body, icon;
|
let body, icon;
|
||||||
if (typeof msg.content === "string") {
|
if (typeof msg.content === "string") {
|
||||||
body = client.markdownToText(msg.content);
|
body = client.markdownToText(msg.content);
|
||||||
icon = client.users.getAvatarURL(msg.author, { max_side: 256 });
|
icon = client.users.getAvatarURL(msg.author, { max_side: 256 });
|
||||||
} else {
|
} else {
|
||||||
let users = client.users;
|
let users = client.users;
|
||||||
switch (msg.content.type) {
|
switch (msg.content.type) {
|
||||||
case "user_added":
|
case "user_added":
|
||||||
case "user_remove":
|
case "user_remove":
|
||||||
body = translate(
|
body = translate(
|
||||||
`app.main.channel.system.${msg.content.type === 'user_added' ? 'added_by' : 'removed_by'}`,
|
`app.main.channel.system.${
|
||||||
{ user: users.get(msg.content.id)?.username, other_user: users.get(msg.content.by)?.username }
|
msg.content.type === "user_added"
|
||||||
);
|
? "added_by"
|
||||||
icon = client.users.getAvatarURL(msg.content.id, { max_side: 256 });
|
: "removed_by"
|
||||||
break;
|
}`,
|
||||||
case "user_joined":
|
{
|
||||||
case "user_left":
|
user: users.get(msg.content.id)?.username,
|
||||||
case "user_kicked":
|
other_user: users.get(msg.content.by)?.username,
|
||||||
case "user_banned":
|
},
|
||||||
body = translate(
|
);
|
||||||
`app.main.channel.system.${msg.content.type}`,
|
icon = client.users.getAvatarURL(msg.content.id, {
|
||||||
{ user: users.get(msg.content.id)?.username }
|
max_side: 256,
|
||||||
);
|
});
|
||||||
icon = client.users.getAvatarURL(msg.content.id, { max_side: 256 });
|
break;
|
||||||
break;
|
case "user_joined":
|
||||||
case "channel_renamed":
|
case "user_left":
|
||||||
body = translate(
|
case "user_kicked":
|
||||||
`app.main.channel.system.channel_renamed`,
|
case "user_banned":
|
||||||
{ user: users.get(msg.content.by)?.username, name: msg.content.name }
|
body = translate(
|
||||||
);
|
`app.main.channel.system.${msg.content.type}`,
|
||||||
icon = client.users.getAvatarURL(msg.content.by, { max_side: 256 });
|
{ user: users.get(msg.content.id)?.username },
|
||||||
break;
|
);
|
||||||
case "channel_description_changed":
|
icon = client.users.getAvatarURL(msg.content.id, {
|
||||||
case "channel_icon_changed":
|
max_side: 256,
|
||||||
body = translate(
|
});
|
||||||
`app.main.channel.system.${msg.content.type}`,
|
break;
|
||||||
{ user: users.get(msg.content.by)?.username }
|
case "channel_renamed":
|
||||||
);
|
body = translate(
|
||||||
icon = client.users.getAvatarURL(msg.content.by, { max_side: 256 });
|
`app.main.channel.system.channel_renamed`,
|
||||||
break;
|
{
|
||||||
}
|
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, {
|
let notif = await createNotification(title, {
|
||||||
icon,
|
icon,
|
||||||
image,
|
image,
|
||||||
body,
|
body,
|
||||||
timestamp: decodeTime(msg._id),
|
timestamp: decodeTime(msg._id),
|
||||||
tag: msg.channel,
|
tag: msg.channel,
|
||||||
badge: '/assets/icons/android-chrome-512x512.png',
|
badge: "/assets/icons/android-chrome-512x512.png",
|
||||||
silent: true
|
silent: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (notif) {
|
if (notif) {
|
||||||
notif.addEventListener("click", () => {
|
notif.addEventListener("click", () => {
|
||||||
window.focus();
|
window.focus();
|
||||||
const id = msg.channel;
|
const id = msg.channel;
|
||||||
if (id !== channel_id) {
|
if (id !== channel_id) {
|
||||||
let channel = client.channels.get(id);
|
let channel = client.channels.get(id);
|
||||||
if (channel) {
|
if (channel) {
|
||||||
if (channel.channel_type === 'TextChannel') {
|
if (channel.channel_type === "TextChannel") {
|
||||||
history.push(`/server/${channel.server}/channel/${id}`);
|
history.push(
|
||||||
} else {
|
`/server/${channel.server}/channel/${id}`,
|
||||||
history.push(`/channel/${id}`);
|
);
|
||||||
}
|
} else {
|
||||||
}
|
history.push(`/channel/${id}`);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
notifications[msg.channel] = notif;
|
notifications[msg.channel] = notif;
|
||||||
notif.addEventListener(
|
notif.addEventListener(
|
||||||
"close",
|
"close",
|
||||||
() => delete notifications[msg.channel]
|
() => delete notifications[msg.channel],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function relationship(user: User, property: string) {
|
async function relationship(user: User, property: string) {
|
||||||
if (client.user?.status?.presence === Users.Presence.Busy) return;
|
if (client.user?.status?.presence === Users.Presence.Busy) return;
|
||||||
if (property !== "relationship") return;
|
if (property !== "relationship") return;
|
||||||
if (!showNotification) return;
|
if (!showNotification) return;
|
||||||
|
|
||||||
let event;
|
let event;
|
||||||
switch (user.relationship) {
|
switch (user.relationship) {
|
||||||
case Users.Relationship.Incoming:
|
case Users.Relationship.Incoming:
|
||||||
event = translate("notifications.sent_request", { person: user.username });
|
event = translate("notifications.sent_request", {
|
||||||
break;
|
person: user.username,
|
||||||
case Users.Relationship.Friend:
|
});
|
||||||
event = translate("notifications.now_friends", { person: user.username });
|
break;
|
||||||
break;
|
case Users.Relationship.Friend:
|
||||||
default:
|
event = translate("notifications.now_friends", {
|
||||||
return;
|
person: user.username,
|
||||||
}
|
});
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let notif = await createNotification(event, {
|
let notif = await createNotification(event, {
|
||||||
icon: client.users.getAvatarURL(user._id, { max_side: 256 }),
|
icon: client.users.getAvatarURL(user._id, { max_side: 256 }),
|
||||||
badge: '/assets/icons/android-chrome-512x512.png',
|
badge: "/assets/icons/android-chrome-512x512.png",
|
||||||
timestamp: +new Date()
|
timestamp: +new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
notif?.addEventListener("click", () => {
|
notif?.addEventListener("click", () => {
|
||||||
history.push(`/friends`);
|
history.push(`/friends`);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
client.addListener("message", message);
|
client.addListener("message", message);
|
||||||
client.users.addListener("mutation", relationship);
|
client.users.addListener("mutation", relationship);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
client.removeListener("message", message);
|
client.removeListener("message", message);
|
||||||
client.users.removeListener("mutation", relationship);
|
client.users.removeListener("mutation", relationship);
|
||||||
};
|
};
|
||||||
}, [client, playSound, guild_id, channel_id, showNotification, notifs]);
|
}, [client, playSound, guild_id, channel_id, showNotification, notifs]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function visChange() {
|
function visChange() {
|
||||||
if (document.visibilityState === "visible") {
|
if (document.visibilityState === "visible") {
|
||||||
if (notifications[channel_id]) {
|
if (notifications[channel_id]) {
|
||||||
notifications[channel_id].close();
|
notifications[channel_id].close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
visChange();
|
visChange();
|
||||||
|
|
||||||
document.addEventListener("visibilitychange", visChange);
|
document.addEventListener("visibilitychange", visChange);
|
||||||
return () =>
|
return () =>
|
||||||
document.removeEventListener("visibilitychange", visChange);
|
document.removeEventListener("visibilitychange", visChange);
|
||||||
}, [guild_id, channel_id]);
|
}, [guild_id, channel_id]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const NotifierComponent = connectState(
|
const NotifierComponent = connectState(
|
||||||
Notifier,
|
Notifier,
|
||||||
state => {
|
(state) => {
|
||||||
return {
|
return {
|
||||||
options: state.settings.notification,
|
options: state.settings.notification,
|
||||||
notifs: state.notifications
|
notifs: state.notifications,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
true
|
true,
|
||||||
);
|
);
|
||||||
|
|
||||||
export default function Notifications() {
|
export default function NotificationsComponent() {
|
||||||
return (
|
return (
|
||||||
<Switch>
|
<Switch>
|
||||||
<Route path="/server/:server/channel/:channel">
|
<Route path="/server/:server/channel/:channel">
|
||||||
<NotifierComponent />
|
<NotifierComponent />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/channel/:channel">
|
<Route path="/channel/:channel">
|
||||||
<NotifierComponent />
|
<NotifierComponent />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/">
|
<Route path="/">
|
||||||
<NotifierComponent />
|
<NotifierComponent />
|
||||||
</Route>
|
</Route>
|
||||||
</Switch>
|
</Switch>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,44 +1,47 @@
|
||||||
import { Text } from "preact-i18n";
|
|
||||||
import styled from "styled-components";
|
|
||||||
import { useContext } from "preact/hooks";
|
|
||||||
import { Children } from "../../types/Preact";
|
|
||||||
import { WifiOff } from "@styled-icons/boxicons-regular";
|
import { WifiOff } from "@styled-icons/boxicons-regular";
|
||||||
|
import styled from "styled-components";
|
||||||
|
|
||||||
|
import { Text } from "preact-i18n";
|
||||||
|
import { useContext } from "preact/hooks";
|
||||||
|
|
||||||
import Preloader from "../../components/ui/Preloader";
|
import Preloader from "../../components/ui/Preloader";
|
||||||
|
|
||||||
|
import { Children } from "../../types/Preact";
|
||||||
import { ClientStatus, StatusContext } from "./RevoltClient";
|
import { ClientStatus, StatusContext } from "./RevoltClient";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
children: Children;
|
children: Children;
|
||||||
}
|
}
|
||||||
|
|
||||||
const Base = styled.div`
|
const Base = styled.div`
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
padding: 1em;
|
padding: 1em;
|
||||||
display: flex;
|
display: flex;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: var(--tertiary-foreground);
|
color: var(--tertiary-foreground);
|
||||||
background: var(--secondary-header);
|
background: var(--secondary-header);
|
||||||
|
|
||||||
> div {
|
> div {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export default function RequiresOnline(props: Props) {
|
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.CONNECTING) return <Preloader type="ring" />;
|
||||||
if (status !== ClientStatus.ONLINE && status !== ClientStatus.READY)
|
if (status !== ClientStatus.ONLINE && status !== ClientStatus.READY)
|
||||||
return (
|
return (
|
||||||
<Base>
|
<Base>
|
||||||
<WifiOff size={16} />
|
<WifiOff size={16} />
|
||||||
<div>
|
<div>
|
||||||
<Text id="app.special.requires_online" />
|
<Text id="app.special.requires_online" />
|
||||||
</div>
|
</div>
|
||||||
</Base>
|
</Base>
|
||||||
);
|
);
|
||||||
|
|
||||||
return <>{ props.children }</>;
|
return <>{props.children}</>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,37 +1,42 @@
|
||||||
import { openDB } from 'idb';
|
import { openDB } from "idb";
|
||||||
|
import { useHistory } from "react-router-dom";
|
||||||
import { Client } from "revolt.js";
|
import { Client } from "revolt.js";
|
||||||
import { takeError } from "./util";
|
|
||||||
import { createContext } from "preact";
|
|
||||||
import { dispatch } from '../../redux';
|
|
||||||
import { Children } from "../../types/Preact";
|
|
||||||
import { useHistory } from 'react-router-dom';
|
|
||||||
import { Route } from "revolt.js/dist/api/routes";
|
import { Route } from "revolt.js/dist/api/routes";
|
||||||
import { connectState } from "../../redux/connector";
|
|
||||||
import Preloader from "../../components/ui/Preloader";
|
import { createContext } from "preact";
|
||||||
import { AuthState } from "../../redux/reducers/auth";
|
|
||||||
import { useEffect, useMemo, useState } from "preact/hooks";
|
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||||
import { useIntermediate } from '../intermediate/Intermediate';
|
|
||||||
|
import { SingletonMessageRenderer } from "../../lib/renderer/Singleton";
|
||||||
|
|
||||||
|
import { dispatch } from "../../redux";
|
||||||
|
import { connectState } from "../../redux/connector";
|
||||||
|
import { AuthState } from "../../redux/reducers/auth";
|
||||||
|
|
||||||
|
import Preloader from "../../components/ui/Preloader";
|
||||||
|
|
||||||
|
import { Children } from "../../types/Preact";
|
||||||
|
import { useIntermediate } from "../intermediate/Intermediate";
|
||||||
import { registerEvents, setReconnectDisallowed } from "./events";
|
import { registerEvents, setReconnectDisallowed } from "./events";
|
||||||
import { SingletonMessageRenderer } from '../../lib/renderer/Singleton';
|
import { takeError } from "./util";
|
||||||
|
|
||||||
export enum ClientStatus {
|
export enum ClientStatus {
|
||||||
INIT,
|
INIT,
|
||||||
LOADING,
|
LOADING,
|
||||||
READY,
|
READY,
|
||||||
OFFLINE,
|
OFFLINE,
|
||||||
DISCONNECTED,
|
DISCONNECTED,
|
||||||
CONNECTING,
|
CONNECTING,
|
||||||
RECONNECTING,
|
RECONNECTING,
|
||||||
ONLINE,
|
ONLINE,
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ClientOperations {
|
export interface ClientOperations {
|
||||||
login: (data: Route<"POST", "/auth/login">["data"]) => Promise<void>;
|
login: (data: Route<"POST", "/auth/login">["data"]) => Promise<void>;
|
||||||
logout: (shouldRequest?: boolean) => Promise<void>;
|
logout: (shouldRequest?: boolean) => Promise<void>;
|
||||||
loggedIn: () => boolean;
|
loggedIn: () => boolean;
|
||||||
ready: () => 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.
|
// By the time they are used, they should all be initialized.
|
||||||
|
@ -42,189 +47,195 @@ export const StatusContext = createContext<ClientStatus>(null!);
|
||||||
export const OperationsContext = createContext<ClientOperations>(null!);
|
export const OperationsContext = createContext<ClientOperations>(null!);
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
auth: AuthState;
|
auth: AuthState;
|
||||||
children: Children;
|
children: Children;
|
||||||
};
|
};
|
||||||
|
|
||||||
function Context({ auth, children }: Props) {
|
function Context({ auth, children }: Props) {
|
||||||
const history = useHistory();
|
const history = useHistory();
|
||||||
const { openScreen } = useIntermediate();
|
const { openScreen } = useIntermediate();
|
||||||
const [status, setStatus] = useState(ClientStatus.INIT);
|
const [status, setStatus] = useState(ClientStatus.INIT);
|
||||||
const [client, setClient] = useState<Client>(undefined as unknown as Client);
|
const [client, setClient] = useState<Client>(
|
||||||
|
undefined as unknown as Client,
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
let db;
|
let db;
|
||||||
try {
|
try {
|
||||||
// Match sw.ts#L23
|
// Match sw.ts#L23
|
||||||
db = await openDB('state', 3, {
|
db = await openDB("state", 3, {
|
||||||
upgrade(db) {
|
upgrade(db) {
|
||||||
for (let store of [ "channels", "servers", "users", "members" ]) {
|
for (let store of [
|
||||||
db.createObjectStore(store, {
|
"channels",
|
||||||
keyPath: '_id'
|
"servers",
|
||||||
});
|
"users",
|
||||||
}
|
"members",
|
||||||
},
|
]) {
|
||||||
});
|
db.createObjectStore(store, {
|
||||||
} catch (err) {
|
keyPath: "_id",
|
||||||
console.error('Failed to open IndexedDB store, continuing without.');
|
});
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
"Failed to open IndexedDB store, continuing without.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
autoReconnect: false,
|
autoReconnect: false,
|
||||||
apiURL: import.meta.env.VITE_API_URL,
|
apiURL: import.meta.env.VITE_API_URL,
|
||||||
debug: import.meta.env.DEV,
|
debug: import.meta.env.DEV,
|
||||||
db
|
db,
|
||||||
});
|
});
|
||||||
|
|
||||||
setClient(client);
|
setClient(client);
|
||||||
SingletonMessageRenderer.subscribe(client);
|
SingletonMessageRenderer.subscribe(client);
|
||||||
setStatus(ClientStatus.LOADING);
|
setStatus(ClientStatus.LOADING);
|
||||||
})();
|
})();
|
||||||
}, [ ]);
|
}, []);
|
||||||
|
|
||||||
if (status === ClientStatus.INIT) return null;
|
if (status === ClientStatus.INIT) return null;
|
||||||
|
|
||||||
const operations: ClientOperations = useMemo(() => {
|
const operations: ClientOperations = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
login: async data => {
|
login: async (data) => {
|
||||||
setReconnectDisallowed(true);
|
setReconnectDisallowed(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const onboarding = await client.login(data);
|
const onboarding = await client.login(data);
|
||||||
setReconnectDisallowed(false);
|
setReconnectDisallowed(false);
|
||||||
const login = () =>
|
const login = () =>
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "LOGIN",
|
type: "LOGIN",
|
||||||
session: client.session! // This [null assertion] is ok, we should have a session by now. - insert's words
|
session: client.session!, // This [null assertion] is ok, we should have a session by now. - insert's words
|
||||||
});
|
});
|
||||||
|
|
||||||
if (onboarding) {
|
if (onboarding) {
|
||||||
openScreen({
|
openScreen({
|
||||||
id: "onboarding",
|
id: "onboarding",
|
||||||
callback: (username: string) =>
|
callback: (username: string) =>
|
||||||
onboarding(username, true).then(login)
|
onboarding(username, true).then(login),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
login();
|
login();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setReconnectDisallowed(false);
|
setReconnectDisallowed(false);
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
logout: async shouldRequest => {
|
logout: async (shouldRequest) => {
|
||||||
dispatch({ type: "LOGOUT" });
|
dispatch({ type: "LOGOUT" });
|
||||||
|
|
||||||
client.reset();
|
client.reset();
|
||||||
dispatch({ type: "RESET" });
|
dispatch({ type: "RESET" });
|
||||||
|
|
||||||
openScreen({ id: "none" });
|
openScreen({ id: "none" });
|
||||||
setStatus(ClientStatus.READY);
|
setStatus(ClientStatus.READY);
|
||||||
|
|
||||||
client.websocket.disconnect();
|
client.websocket.disconnect();
|
||||||
|
|
||||||
if (shouldRequest) {
|
if (shouldRequest) {
|
||||||
try {
|
try {
|
||||||
await client.logout();
|
await client.logout();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
loggedIn: () => typeof auth.active !== "undefined",
|
loggedIn: () => typeof auth.active !== "undefined",
|
||||||
ready: () => (
|
ready: () =>
|
||||||
operations.loggedIn() &&
|
operations.loggedIn() && typeof client.user !== "undefined",
|
||||||
typeof client.user !== "undefined"
|
openDM: async (user_id: string) => {
|
||||||
),
|
let channel = await client.users.openDM(user_id);
|
||||||
openDM: async (user_id: string) => {
|
history.push(`/channel/${channel!._id}`);
|
||||||
let channel = await client.users.openDM(user_id);
|
return channel!._id;
|
||||||
history.push(`/channel/${channel!._id}`);
|
},
|
||||||
return channel!._id;
|
};
|
||||||
}
|
}, [client, auth.active]);
|
||||||
}
|
|
||||||
}, [ client, auth.active ]);
|
|
||||||
|
|
||||||
useEffect(() => registerEvents({ operations }, setStatus, client), [ client ]);
|
useEffect(
|
||||||
|
() => registerEvents({ operations }, setStatus, client),
|
||||||
|
[client],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
if (client.db) {
|
if (client.db) {
|
||||||
await client.restore();
|
await client.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (auth.active) {
|
if (auth.active) {
|
||||||
dispatch({ type: "QUEUE_FAIL_ALL" });
|
dispatch({ type: "QUEUE_FAIL_ALL" });
|
||||||
|
|
||||||
const active = auth.accounts[auth.active];
|
const active = auth.accounts[auth.active];
|
||||||
client.user = client.users.get(active.session.user_id);
|
client.user = client.users.get(active.session.user_id);
|
||||||
if (!navigator.onLine) {
|
if (!navigator.onLine) {
|
||||||
return setStatus(ClientStatus.OFFLINE);
|
return setStatus(ClientStatus.OFFLINE);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (operations.ready())
|
if (operations.ready()) setStatus(ClientStatus.CONNECTING);
|
||||||
setStatus(ClientStatus.CONNECTING);
|
|
||||||
|
|
||||||
if (navigator.onLine) {
|
|
||||||
await client
|
|
||||||
.fetchConfiguration()
|
|
||||||
.catch(() =>
|
|
||||||
console.error("Failed to connect to API server.")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
if (navigator.onLine) {
|
||||||
await client.fetchConfiguration();
|
await client
|
||||||
const callback = await client.useExistingSession(
|
.fetchConfiguration()
|
||||||
active.session
|
.catch(() =>
|
||||||
);
|
console.error("Failed to connect to API server."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (callback) {
|
try {
|
||||||
openScreen({ id: "onboarding", callback });
|
await client.fetchConfiguration();
|
||||||
}
|
const callback = await client.useExistingSession(
|
||||||
} catch (err) {
|
active.session,
|
||||||
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);
|
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 (status === ClientStatus.LOADING) {
|
setStatus(ClientStatus.READY);
|
||||||
return <Preloader type="spinner" />;
|
}
|
||||||
}
|
})();
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
if (status === ClientStatus.LOADING) {
|
||||||
<AppContext.Provider value={client}>
|
return <Preloader type="spinner" />;
|
||||||
<StatusContext.Provider value={status}>
|
}
|
||||||
<OperationsContext.Provider value={operations}>
|
|
||||||
{ children }
|
return (
|
||||||
</OperationsContext.Provider>
|
<AppContext.Provider value={client}>
|
||||||
</StatusContext.Provider>
|
<StatusContext.Provider value={status}>
|
||||||
</AppContext.Provider>
|
<OperationsContext.Provider value={operations}>
|
||||||
);
|
{children}
|
||||||
|
</OperationsContext.Provider>
|
||||||
|
</StatusContext.Provider>
|
||||||
|
</AppContext.Provider>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connectState<{ children: Children }>(
|
export default connectState<{ children: Children }>(Context, (state) => {
|
||||||
Context,
|
return {
|
||||||
state => {
|
auth: state.auth,
|
||||||
return {
|
sync: state.sync,
|
||||||
auth: state.auth,
|
};
|
||||||
sync: state.sync
|
});
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
|
@ -1,77 +1,76 @@
|
||||||
/**
|
/**
|
||||||
* This file monitors the message cache to delete any queued messages that have already sent.
|
* This file monitors the message cache to delete any queued messages that have already sent.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Message } from "revolt.js";
|
import { Message } from "revolt.js";
|
||||||
import { AppContext } from "./RevoltClient";
|
|
||||||
import { Typing } from "../../redux/reducers/typing";
|
|
||||||
import { useContext, useEffect } from "preact/hooks";
|
import { useContext, useEffect } from "preact/hooks";
|
||||||
|
|
||||||
|
import { dispatch } from "../../redux";
|
||||||
import { connectState } from "../../redux/connector";
|
import { connectState } from "../../redux/connector";
|
||||||
import { QueuedMessage } from "../../redux/reducers/queue";
|
import { QueuedMessage } from "../../redux/reducers/queue";
|
||||||
import { dispatch } from "../../redux";
|
import { Typing } from "../../redux/reducers/typing";
|
||||||
|
|
||||||
|
import { AppContext } from "./RevoltClient";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
messages: QueuedMessage[];
|
messages: QueuedMessage[];
|
||||||
typing: Typing
|
typing: Typing;
|
||||||
};
|
};
|
||||||
|
|
||||||
function StateMonitor(props: Props) {
|
function StateMonitor(props: Props) {
|
||||||
const client = useContext(AppContext);
|
const client = useContext(AppContext);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'QUEUE_DROP_ALL'
|
type: "QUEUE_DROP_ALL",
|
||||||
});
|
});
|
||||||
}, [ ]);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function add(msg: Message) {
|
function add(msg: Message) {
|
||||||
if (!msg.nonce) return;
|
if (!msg.nonce) return;
|
||||||
if (!props.messages.find(x => x.id === msg.nonce)) return;
|
if (!props.messages.find((x) => x.id === msg.nonce)) return;
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'QUEUE_REMOVE',
|
type: "QUEUE_REMOVE",
|
||||||
nonce: msg.nonce
|
nonce: msg.nonce,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
client.addListener('message', add);
|
client.addListener("message", add);
|
||||||
return () => client.removeListener('message', add);
|
return () => client.removeListener("message", add);
|
||||||
}, [ props.messages ]);
|
}, [props.messages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function removeOld() {
|
function removeOld() {
|
||||||
if (!props.typing) return;
|
if (!props.typing) return;
|
||||||
for (let channel of Object.keys(props.typing)) {
|
for (let channel of Object.keys(props.typing)) {
|
||||||
let users = props.typing[channel];
|
let users = props.typing[channel];
|
||||||
|
|
||||||
for (let user of users) {
|
for (let user of users) {
|
||||||
if (+ new Date() > user.started + 5000) {
|
if (+new Date() > user.started + 5000) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'TYPING_STOP',
|
type: "TYPING_STOP",
|
||||||
channel,
|
channel,
|
||||||
user: user.id
|
user: user.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
removeOld();
|
removeOld();
|
||||||
|
|
||||||
let interval = setInterval(removeOld, 1000);
|
let interval = setInterval(removeOld, 1000);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [ props.typing ]);
|
}, [props.typing]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connectState(
|
export default connectState(StateMonitor, (state) => {
|
||||||
StateMonitor,
|
return {
|
||||||
state => {
|
messages: [...state.queue],
|
||||||
return {
|
typing: state.typing,
|
||||||
messages: [...state.queue],
|
};
|
||||||
typing: state.typing
|
});
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
|
@ -1,126 +1,146 @@
|
||||||
/**
|
/**
|
||||||
* This file monitors changes to settings and syncs them to the server.
|
* This file monitors changes to settings and syncs them to the server.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import isEqual from "lodash.isequal";
|
import isEqual from "lodash.isequal";
|
||||||
import { Language } from "../Locale";
|
|
||||||
import { Sync } from "revolt.js/dist/api/objects";
|
import { Sync } from "revolt.js/dist/api/objects";
|
||||||
import { useContext, useEffect } from "preact/hooks";
|
|
||||||
import { connectState } from "../../redux/connector";
|
|
||||||
import { Settings } from "../../redux/reducers/settings";
|
|
||||||
import { Notifications } from "../../redux/reducers/notifications";
|
|
||||||
import { AppContext, ClientStatus, StatusContext } from "./RevoltClient";
|
|
||||||
import { ClientboundNotification } from "revolt.js/dist/websocket/notifications";
|
import { ClientboundNotification } from "revolt.js/dist/websocket/notifications";
|
||||||
import { DEFAULT_ENABLED_SYNC, SyncData, SyncKeys, SyncOptions } from "../../redux/reducers/sync";
|
|
||||||
|
import { useContext, useEffect } from "preact/hooks";
|
||||||
|
|
||||||
import { dispatch } from "../../redux";
|
import { dispatch } from "../../redux";
|
||||||
|
import { connectState } from "../../redux/connector";
|
||||||
|
import { Notifications } from "../../redux/reducers/notifications";
|
||||||
|
import { Settings } from "../../redux/reducers/settings";
|
||||||
|
import {
|
||||||
|
DEFAULT_ENABLED_SYNC,
|
||||||
|
SyncData,
|
||||||
|
SyncKeys,
|
||||||
|
SyncOptions,
|
||||||
|
} from "../../redux/reducers/sync";
|
||||||
|
|
||||||
|
import { Language } from "../Locale";
|
||||||
|
import { AppContext, ClientStatus, StatusContext } from "./RevoltClient";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
settings: Settings,
|
settings: Settings;
|
||||||
locale: Language,
|
locale: Language;
|
||||||
sync: SyncOptions,
|
sync: SyncOptions;
|
||||||
notifications: Notifications
|
notifications: Notifications;
|
||||||
};
|
};
|
||||||
|
|
||||||
var lastValues: { [key in SyncKeys]?: any } = { };
|
var lastValues: { [key in SyncKeys]?: any } = {};
|
||||||
|
|
||||||
export function mapSync(packet: Sync.UserSettings, revision?: Record<string, number>) {
|
export function mapSync(
|
||||||
let update: { [key in SyncKeys]?: [ number, SyncData[key] ] } = {};
|
packet: Sync.UserSettings,
|
||||||
for (let key of Object.keys(packet)) {
|
revision?: Record<string, number>,
|
||||||
let [ timestamp, obj ] = packet[key];
|
) {
|
||||||
if (timestamp < (revision ?? {})[key] ?? 0) {
|
let update: { [key in SyncKeys]?: [number, SyncData[key]] } = {};
|
||||||
continue;
|
for (let key of Object.keys(packet)) {
|
||||||
}
|
let [timestamp, obj] = packet[key];
|
||||||
|
if (timestamp < (revision ?? {})[key] ?? 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
let object;
|
let object;
|
||||||
if (obj[0] === '{') {
|
if (obj[0] === "{") {
|
||||||
object = JSON.parse(obj)
|
object = JSON.parse(obj);
|
||||||
} else {
|
} else {
|
||||||
object = obj;
|
object = obj;
|
||||||
}
|
}
|
||||||
|
|
||||||
lastValues[key as SyncKeys] = object;
|
lastValues[key as SyncKeys] = object;
|
||||||
update[key as SyncKeys] = [ timestamp, object ];
|
update[key as SyncKeys] = [timestamp, object];
|
||||||
}
|
}
|
||||||
|
|
||||||
return update;
|
return update;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SyncManager(props: Props) {
|
function SyncManager(props: Props) {
|
||||||
const client = useContext(AppContext);
|
const client = useContext(AppContext);
|
||||||
const status = useContext(StatusContext);
|
const status = useContext(StatusContext);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status === ClientStatus.ONLINE) {
|
if (status === ClientStatus.ONLINE) {
|
||||||
client
|
client
|
||||||
.syncFetchSettings(DEFAULT_ENABLED_SYNC.filter(x => !props.sync?.disabled?.includes(x)))
|
.syncFetchSettings(
|
||||||
.then(data => {
|
DEFAULT_ENABLED_SYNC.filter(
|
||||||
dispatch({
|
(x) => !props.sync?.disabled?.includes(x),
|
||||||
type: 'SYNC_UPDATE',
|
),
|
||||||
update: mapSync(data)
|
)
|
||||||
});
|
.then((data) => {
|
||||||
});
|
dispatch({
|
||||||
|
type: "SYNC_UPDATE",
|
||||||
|
update: mapSync(data),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
client
|
client
|
||||||
.syncFetchUnreads()
|
.syncFetchUnreads()
|
||||||
.then(unreads => dispatch({ type: 'UNREADS_SET', unreads }));
|
.then((unreads) => dispatch({ type: "UNREADS_SET", unreads }));
|
||||||
}
|
}
|
||||||
}, [ status ]);
|
}, [status]);
|
||||||
|
|
||||||
function syncChange(key: SyncKeys, data: any) {
|
function syncChange(key: SyncKeys, data: any) {
|
||||||
let timestamp = + new Date();
|
let timestamp = +new Date();
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'SYNC_SET_REVISION',
|
type: "SYNC_SET_REVISION",
|
||||||
key,
|
key,
|
||||||
timestamp
|
timestamp,
|
||||||
});
|
});
|
||||||
|
|
||||||
client.syncSetSettings({
|
client.syncSetSettings(
|
||||||
[key]: data
|
{
|
||||||
}, timestamp);
|
[key]: data,
|
||||||
}
|
},
|
||||||
|
timestamp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let disabled = props.sync.disabled ?? [];
|
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][]) {
|
for (let [key, object] of [
|
||||||
useEffect(() => {
|
["appearance", props.settings.appearance],
|
||||||
if (disabled.indexOf(key) === -1) {
|
["theme", props.settings.theme],
|
||||||
if (typeof lastValues[key] !== 'undefined') {
|
["locale", props.locale],
|
||||||
if (!isEqual(lastValues[key], object)) {
|
["notifications", props.notifications],
|
||||||
syncChange(key, object);
|
] 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;
|
lastValues[key] = object;
|
||||||
}, [ disabled, object ]);
|
}, [disabled, object]);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onPacket(packet: ClientboundNotification) {
|
function onPacket(packet: ClientboundNotification) {
|
||||||
if (packet.type === 'UserSettingsUpdate') {
|
if (packet.type === "UserSettingsUpdate") {
|
||||||
let update: { [key in SyncKeys]?: [ number, SyncData[key] ] } = mapSync(packet.update, props.sync.revision);
|
let update: { [key in SyncKeys]?: [number, SyncData[key]] } =
|
||||||
|
mapSync(packet.update, props.sync.revision);
|
||||||
|
|
||||||
dispatch({
|
dispatch({
|
||||||
type: 'SYNC_UPDATE',
|
type: "SYNC_UPDATE",
|
||||||
update
|
update,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
client.addListener('packet', onPacket);
|
client.addListener("packet", onPacket);
|
||||||
return () => client.removeListener('packet', onPacket);
|
return () => client.removeListener("packet", onPacket);
|
||||||
}, [ disabled, props.sync ]);
|
}, [disabled, props.sync]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default connectState(
|
export default connectState(SyncManager, (state) => {
|
||||||
SyncManager,
|
return {
|
||||||
state => {
|
settings: state.settings,
|
||||||
return {
|
locale: state.locale,
|
||||||
settings: state.settings,
|
sync: state.sync,
|
||||||
locale: state.locale,
|
notifications: state.notifications,
|
||||||
sync: state.sync,
|
};
|
||||||
notifications: state.notifications
|
});
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
|
@ -1,148 +1,155 @@
|
||||||
import { ClientboundNotification } from "revolt.js/dist/websocket/notifications";
|
|
||||||
import { Client, Message } from "revolt.js/dist";
|
import { Client, Message } from "revolt.js/dist";
|
||||||
import {
|
import { ClientboundNotification } from "revolt.js/dist/websocket/notifications";
|
||||||
ClientOperations,
|
|
||||||
ClientStatus
|
|
||||||
} from "./RevoltClient";
|
|
||||||
import { StateUpdater } from "preact/hooks";
|
import { StateUpdater } from "preact/hooks";
|
||||||
|
|
||||||
import { dispatch } from "../../redux";
|
import { dispatch } from "../../redux";
|
||||||
|
|
||||||
|
import { ClientOperations, ClientStatus } from "./RevoltClient";
|
||||||
|
|
||||||
export var preventReconnect = false;
|
export var preventReconnect = false;
|
||||||
let preventUntil = 0;
|
let preventUntil = 0;
|
||||||
|
|
||||||
export function setReconnectDisallowed(allowed: boolean) {
|
export function setReconnectDisallowed(allowed: boolean) {
|
||||||
preventReconnect = allowed;
|
preventReconnect = allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerEvents({
|
export function registerEvents(
|
||||||
operations
|
{ operations }: { operations: ClientOperations },
|
||||||
}: { operations: ClientOperations }, setStatus: StateUpdater<ClientStatus>, client: Client) {
|
setStatus: StateUpdater<ClientStatus>,
|
||||||
function attemptReconnect() {
|
client: Client,
|
||||||
if (preventReconnect) return;
|
) {
|
||||||
function reconnect() {
|
function attemptReconnect() {
|
||||||
preventUntil = +new Date() + 2000;
|
if (preventReconnect) return;
|
||||||
client.websocket.connect().catch(err => console.error(err));
|
function reconnect() {
|
||||||
}
|
preventUntil = +new Date() + 2000;
|
||||||
|
client.websocket.connect().catch((err) => console.error(err));
|
||||||
|
}
|
||||||
|
|
||||||
if (+new Date() > preventUntil) {
|
if (+new Date() > preventUntil) {
|
||||||
setTimeout(reconnect, 2000);
|
setTimeout(reconnect, 2000);
|
||||||
} else {
|
} else {
|
||||||
reconnect();
|
reconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let listeners: Record<string, (...args: any[]) => void> = {
|
let listeners: Record<string, (...args: any[]) => void> = {
|
||||||
connecting: () =>
|
connecting: () =>
|
||||||
operations.ready() && setStatus(ClientStatus.CONNECTING),
|
operations.ready() && setStatus(ClientStatus.CONNECTING),
|
||||||
|
|
||||||
dropped: () => {
|
dropped: () => {
|
||||||
if (operations.ready()) {
|
if (operations.ready()) {
|
||||||
setStatus(ClientStatus.DISCONNECTED);
|
setStatus(ClientStatus.DISCONNECTED);
|
||||||
attemptReconnect();
|
attemptReconnect();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
packet: (packet: ClientboundNotification) => {
|
packet: (packet: ClientboundNotification) => {
|
||||||
switch (packet.type) {
|
switch (packet.type) {
|
||||||
case "ChannelStartTyping": {
|
case "ChannelStartTyping": {
|
||||||
if (packet.user === client.user?._id) return;
|
if (packet.user === client.user?._id) return;
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "TYPING_START",
|
type: "TYPING_START",
|
||||||
channel: packet.id,
|
channel: packet.id,
|
||||||
user: packet.user
|
user: packet.user,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "ChannelStopTyping": {
|
case "ChannelStopTyping": {
|
||||||
if (packet.user === client.user?._id) return;
|
if (packet.user === client.user?._id) return;
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "TYPING_STOP",
|
type: "TYPING_STOP",
|
||||||
channel: packet.id,
|
channel: packet.id,
|
||||||
user: packet.user
|
user: packet.user,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "ChannelAck": {
|
case "ChannelAck": {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "UNREADS_MARK_READ",
|
type: "UNREADS_MARK_READ",
|
||||||
channel: packet.id,
|
channel: packet.id,
|
||||||
message: packet.message_id
|
message: packet.message_id,
|
||||||
});
|
});
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
message: (message: Message) => {
|
message: (message: Message) => {
|
||||||
if (message.mentions?.includes(client.user!._id)) {
|
if (message.mentions?.includes(client.user!._id)) {
|
||||||
dispatch({
|
dispatch({
|
||||||
type: "UNREADS_MENTION",
|
type: "UNREADS_MENTION",
|
||||||
channel: message.channel,
|
channel: message.channel,
|
||||||
message: message._id
|
message: message._id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
ready: () => setStatus(ClientStatus.ONLINE)
|
ready: () => setStatus(ClientStatus.ONLINE),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
listeners = new Proxy(listeners, {
|
listeners = new Proxy(listeners, {
|
||||||
get: (target, listener, receiver) => (...args: unknown[]) => {
|
get:
|
||||||
console.debug(`Calling ${listener.toString()} with`, args);
|
(target, listener, receiver) =>
|
||||||
Reflect.get(target, listener)(...args)
|
(...args: unknown[]) => {
|
||||||
}
|
console.debug(`Calling ${listener.toString()} with`, args);
|
||||||
})
|
Reflect.get(target, listener)(...args);
|
||||||
}
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: clean this a bit and properly handle types
|
// TODO: clean this a bit and properly handle types
|
||||||
for (const listener in listeners) {
|
for (const listener in listeners) {
|
||||||
client.addListener(listener, listeners[listener]);
|
client.addListener(listener, listeners[listener]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function logMutation(target: string, key: string) {
|
function logMutation(target: string, key: string) {
|
||||||
console.log('(o) Object mutated', target, '\nChanged:', key);
|
console.log("(o) Object mutated", target, "\nChanged:", key);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
client.users.addListener('mutation', logMutation);
|
client.users.addListener("mutation", logMutation);
|
||||||
client.servers.addListener('mutation', logMutation);
|
client.servers.addListener("mutation", logMutation);
|
||||||
client.channels.addListener('mutation', logMutation);
|
client.channels.addListener("mutation", logMutation);
|
||||||
client.servers.members.addListener('mutation', logMutation);
|
client.servers.members.addListener("mutation", logMutation);
|
||||||
}
|
}
|
||||||
|
|
||||||
const online = () => {
|
const online = () => {
|
||||||
if (operations.ready()) {
|
if (operations.ready()) {
|
||||||
setStatus(ClientStatus.RECONNECTING);
|
setStatus(ClientStatus.RECONNECTING);
|
||||||
setReconnectDisallowed(false);
|
setReconnectDisallowed(false);
|
||||||
attemptReconnect();
|
attemptReconnect();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const offline = () => {
|
|
||||||
if (operations.ready()) {
|
|
||||||
setReconnectDisallowed(true);
|
|
||||||
client.websocket.disconnect();
|
|
||||||
setStatus(ClientStatus.OFFLINE);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("online", online);
|
const offline = () => {
|
||||||
window.addEventListener("offline", offline);
|
if (operations.ready()) {
|
||||||
|
setReconnectDisallowed(true);
|
||||||
|
client.websocket.disconnect();
|
||||||
|
setStatus(ClientStatus.OFFLINE);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return () => {
|
window.addEventListener("online", online);
|
||||||
for (const listener in listeners) {
|
window.addEventListener("offline", offline);
|
||||||
client.removeListener(listener, listeners[listener as keyof typeof listeners]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
return () => {
|
||||||
client.users.removeListener('mutation', logMutation);
|
for (const listener in listeners) {
|
||||||
client.servers.removeListener('mutation', logMutation);
|
client.removeListener(
|
||||||
client.channels.removeListener('mutation', logMutation);
|
listener,
|
||||||
client.servers.members.removeListener('mutation', logMutation);
|
listeners[listener as keyof typeof listeners],
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
|
||||||
window.removeEventListener("online", online);
|
if (import.meta.env.DEV) {
|
||||||
window.removeEventListener("offline", offline);
|
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);
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,177 +1,232 @@
|
||||||
import { useCallback, useContext, useEffect, useState } from "preact/hooks";
|
import { Client, PermissionCalculator } from "revolt.js";
|
||||||
import { Channels, Servers, Users } from "revolt.js/dist/api/objects";
|
import { Channels, Servers, Users } from "revolt.js/dist/api/objects";
|
||||||
import { Client, PermissionCalculator } from 'revolt.js';
|
|
||||||
import { AppContext } from "./RevoltClient";
|
|
||||||
import Collection from "revolt.js/dist/maps/Collection";
|
import Collection from "revolt.js/dist/maps/Collection";
|
||||||
|
|
||||||
|
import { useCallback, useContext, useEffect, useState } from "preact/hooks";
|
||||||
|
|
||||||
|
import { AppContext } from "./RevoltClient";
|
||||||
|
|
||||||
export interface HookContext {
|
export interface HookContext {
|
||||||
client: Client,
|
client: Client;
|
||||||
forceUpdate: () => void
|
forceUpdate: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useForceUpdate(context?: HookContext): HookContext {
|
export function useForceUpdate(context?: HookContext): HookContext {
|
||||||
const client = useContext(AppContext);
|
const client = useContext(AppContext);
|
||||||
if (context) return context;
|
if (context) return context;
|
||||||
|
|
||||||
const H = useState(0);
|
const H = useState(0);
|
||||||
var updateState: (_: number) => void;
|
var updateState: (_: number) => void;
|
||||||
if (Array.isArray(H)) {
|
if (Array.isArray(H)) {
|
||||||
let [, u] = H;
|
let [, u] = H;
|
||||||
updateState = u;
|
updateState = u;
|
||||||
} else {
|
} else {
|
||||||
console.warn('Failed to construct using useState.');
|
console.warn("Failed to construct using useState.");
|
||||||
updateState = ()=>{};
|
updateState = () => {};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { client, forceUpdate: () => updateState(Math.random()) };
|
return { client, forceUpdate: () => updateState(Math.random()) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: utils.d.ts maybe?
|
// TODO: utils.d.ts maybe?
|
||||||
type PickProperties<T, U> = Pick<T, {
|
type PickProperties<T, U> = Pick<
|
||||||
[K in keyof T]: T[K] extends U ? K : never
|
T,
|
||||||
}[keyof T]>
|
{
|
||||||
|
[K in keyof T]: T[K] extends U ? K : never;
|
||||||
|
}[keyof T]
|
||||||
|
>;
|
||||||
|
|
||||||
// The keys in Client that are an object
|
// 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
|
// 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>;
|
type ClientCollectionKey = Exclude<
|
||||||
|
keyof PickProperties<Client, Collection<any>>,
|
||||||
|
undefined
|
||||||
|
>;
|
||||||
|
|
||||||
function useObject(type: ClientCollectionKey, id?: string | string[], context?: HookContext) {
|
function useObject(
|
||||||
const ctx = useForceUpdate(context);
|
type: ClientCollectionKey,
|
||||||
|
id?: string | string[],
|
||||||
|
context?: HookContext,
|
||||||
|
) {
|
||||||
|
const ctx = useForceUpdate(context);
|
||||||
|
|
||||||
function update(target: any) {
|
function update(target: any) {
|
||||||
if (typeof id === 'string' ? target === id :
|
if (
|
||||||
Array.isArray(id) ? id.includes(target) : true) {
|
typeof id === "string"
|
||||||
ctx.forceUpdate();
|
? target === id
|
||||||
}
|
: Array.isArray(id)
|
||||||
}
|
? id.includes(target)
|
||||||
|
: true
|
||||||
|
) {
|
||||||
|
ctx.forceUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const map = ctx.client[type];
|
const map = ctx.client[type];
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
map.addListener("update", update);
|
map.addListener("update", update);
|
||||||
return () => map.removeListener("update", update);
|
return () => map.removeListener("update", update);
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
return typeof id === 'string' ? map.get(id)
|
return typeof id === "string"
|
||||||
: Array.isArray(id) ? id.map(x => map.get(x))
|
? map.get(id)
|
||||||
: map.toArray();
|
: Array.isArray(id)
|
||||||
|
? id.map((x) => map.get(x))
|
||||||
|
: map.toArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUser(id?: string, context?: HookContext) {
|
export function useUser(id?: string, context?: HookContext) {
|
||||||
if (typeof id === "undefined") return;
|
if (typeof id === "undefined") return;
|
||||||
return useObject('users', id, context) as Readonly<Users.User> | undefined;
|
return useObject("users", id, context) as Readonly<Users.User> | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSelf(context?: HookContext) {
|
export function useSelf(context?: HookContext) {
|
||||||
const ctx = useForceUpdate(context);
|
const ctx = useForceUpdate(context);
|
||||||
return useUser(ctx.client.user!._id, ctx);
|
return useUser(ctx.client.user!._id, ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUsers(ids?: string[], context?: HookContext) {
|
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) {
|
export function useChannel(id?: string, context?: HookContext) {
|
||||||
if (typeof id === "undefined") return;
|
if (typeof id === "undefined") return;
|
||||||
return useObject('channels', id, context) as Readonly<Channels.Channel> | undefined;
|
return useObject("channels", id, context) as
|
||||||
|
| Readonly<Channels.Channel>
|
||||||
|
| undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useChannels(ids?: string[], context?: HookContext) {
|
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) {
|
export function useServer(id?: string, context?: HookContext) {
|
||||||
if (typeof id === "undefined") return;
|
if (typeof id === "undefined") return;
|
||||||
return useObject('servers', id, context) as Readonly<Servers.Server> | undefined;
|
return useObject("servers", id, context) as
|
||||||
|
| Readonly<Servers.Server>
|
||||||
|
| undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useServers(ids?: string[], context?: HookContext) {
|
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) {
|
export function useDMs(context?: HookContext) {
|
||||||
const ctx = useForceUpdate(context);
|
const ctx = useForceUpdate(context);
|
||||||
|
|
||||||
function mutation(target: string) {
|
function mutation(target: string) {
|
||||||
let channel = ctx.client.channels.get(target);
|
let channel = ctx.client.channels.get(target);
|
||||||
if (channel) {
|
if (channel) {
|
||||||
if (channel.channel_type === 'DirectMessage' || channel.channel_type === 'Group') {
|
if (
|
||||||
ctx.forceUpdate();
|
channel.channel_type === "DirectMessage" ||
|
||||||
}
|
channel.channel_type === "Group"
|
||||||
}
|
) {
|
||||||
}
|
ctx.forceUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const map = ctx.client.channels;
|
const map = ctx.client.channels;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
map.addListener("update", mutation);
|
map.addListener("update", mutation);
|
||||||
return () => map.removeListener("update", mutation);
|
return () => map.removeListener("update", mutation);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return map
|
return map
|
||||||
.toArray()
|
.toArray()
|
||||||
.filter(x => x.channel_type === 'DirectMessage' || x.channel_type === 'Group' || x.channel_type === 'SavedMessages') as (Channels.GroupChannel | Channels.DirectMessageChannel | Channels.SavedMessagesChannel)[];
|
.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) {
|
export function useUserPermission(id: string, context?: HookContext) {
|
||||||
const ctx = useForceUpdate(context);
|
const ctx = useForceUpdate(context);
|
||||||
|
|
||||||
const mutation = (target: string) => (target === id) && ctx.forceUpdate();
|
const mutation = (target: string) => target === id && ctx.forceUpdate();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ctx.client.users.addListener("update", mutation);
|
ctx.client.users.addListener("update", mutation);
|
||||||
return () => ctx.client.users.removeListener("update", mutation);
|
return () => ctx.client.users.removeListener("update", mutation);
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
let calculator = new PermissionCalculator(ctx.client);
|
let calculator = new PermissionCalculator(ctx.client);
|
||||||
return calculator.forUser(id);
|
return calculator.forUser(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useChannelPermission(id: string, context?: HookContext) {
|
export function useChannelPermission(id: string, context?: HookContext) {
|
||||||
const ctx = useForceUpdate(context);
|
const ctx = useForceUpdate(context);
|
||||||
|
|
||||||
const channel = ctx.client.channels.get(id);
|
const channel = ctx.client.channels.get(id);
|
||||||
const server = (channel && (channel.channel_type === 'TextChannel' || channel.channel_type === 'VoiceChannel')) ? channel.server : undefined;
|
const server =
|
||||||
|
channel &&
|
||||||
|
(channel.channel_type === "TextChannel" ||
|
||||||
|
channel.channel_type === "VoiceChannel")
|
||||||
|
? channel.server
|
||||||
|
: undefined;
|
||||||
|
|
||||||
const mutation = (target: string) => (target === id) && ctx.forceUpdate();
|
const mutation = (target: string) => target === id && ctx.forceUpdate();
|
||||||
const mutationServer = (target: string) => (target === server) && ctx.forceUpdate();
|
const mutationServer = (target: string) =>
|
||||||
const mutationMember = (target: string) => (target.substr(26) === ctx.client.user!._id) && ctx.forceUpdate();
|
target === server && ctx.forceUpdate();
|
||||||
|
const mutationMember = (target: string) =>
|
||||||
|
target.substr(26) === ctx.client.user!._id && ctx.forceUpdate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ctx.client.channels.addListener("update", mutation);
|
ctx.client.channels.addListener("update", mutation);
|
||||||
|
|
||||||
if (server) {
|
if (server) {
|
||||||
ctx.client.servers.addListener("update", mutationServer);
|
ctx.client.servers.addListener("update", mutationServer);
|
||||||
ctx.client.servers.members.addListener("update", mutationMember);
|
ctx.client.servers.members.addListener("update", mutationMember);
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
ctx.client.channels.removeListener("update", mutation);
|
ctx.client.channels.removeListener("update", mutation);
|
||||||
|
|
||||||
if (server) {
|
if (server) {
|
||||||
ctx.client.servers.removeListener("update", mutationServer);
|
ctx.client.servers.removeListener("update", mutationServer);
|
||||||
ctx.client.servers.members.removeListener("update", mutationMember);
|
ctx.client.servers.members.removeListener(
|
||||||
}
|
"update",
|
||||||
}
|
mutationMember,
|
||||||
}, [id]);
|
);
|
||||||
|
}
|
||||||
let calculator = new PermissionCalculator(ctx.client);
|
};
|
||||||
return calculator.forChannel(id);
|
}, [id]);
|
||||||
|
|
||||||
|
let calculator = new PermissionCalculator(ctx.client);
|
||||||
|
return calculator.forChannel(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useServerPermission(id: string, context?: HookContext) {
|
export function useServerPermission(id: string, context?: HookContext) {
|
||||||
const ctx = useForceUpdate(context);
|
const ctx = useForceUpdate(context);
|
||||||
|
|
||||||
const mutation = (target: string) => (target === id) && ctx.forceUpdate();
|
const mutation = (target: string) => target === id && ctx.forceUpdate();
|
||||||
const mutationMember = (target: string) => (target.substr(26) === ctx.client.user!._id) && ctx.forceUpdate();
|
const mutationMember = (target: string) =>
|
||||||
|
target.substr(26) === ctx.client.user!._id && ctx.forceUpdate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ctx.client.servers.addListener("update", mutation);
|
ctx.client.servers.addListener("update", mutation);
|
||||||
ctx.client.servers.members.addListener("update", mutationMember);
|
ctx.client.servers.members.addListener("update", mutationMember);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
ctx.client.servers.removeListener("update", mutation);
|
ctx.client.servers.removeListener("update", mutation);
|
||||||
ctx.client.servers.members.removeListener("update", mutationMember);
|
ctx.client.servers.members.removeListener("update", mutationMember);
|
||||||
}
|
};
|
||||||
}, [id]);
|
}, [id]);
|
||||||
|
|
||||||
let calculator = new PermissionCalculator(ctx.client);
|
let calculator = new PermissionCalculator(ctx.client);
|
||||||
return calculator.forServer(id);
|
return calculator.forServer(id);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,48 +1,57 @@
|
||||||
import { Channel, Message, User } from "revolt.js/dist/api/objects";
|
|
||||||
import { Children } from "../../types/Preact";
|
|
||||||
import { Text } from "preact-i18n";
|
|
||||||
import { Client } from "revolt.js";
|
import { Client } from "revolt.js";
|
||||||
|
import { Channel, Message, User } from "revolt.js/dist/api/objects";
|
||||||
|
|
||||||
export function takeError(
|
import { Text } from "preact-i18n";
|
||||||
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";
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error(error);
|
import { Children } from "../../types/Preact";
|
||||||
return "UnknownError";
|
|
||||||
}
|
|
||||||
|
|
||||||
return id;
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
console.error(error);
|
||||||
|
return "UnknownError";
|
||||||
|
}
|
||||||
|
|
||||||
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getChannelName(client: Client, channel: Channel, prefixType?: boolean): Children {
|
export function getChannelName(
|
||||||
if (channel.channel_type === "SavedMessages")
|
client: Client,
|
||||||
return <Text id="app.navigation.tabs.saved" />;
|
channel: Channel,
|
||||||
|
prefixType?: boolean,
|
||||||
|
): Children {
|
||||||
|
if (channel.channel_type === "SavedMessages")
|
||||||
|
return <Text id="app.navigation.tabs.saved" />;
|
||||||
|
|
||||||
if (channel.channel_type === "DirectMessage") {
|
if (channel.channel_type === "DirectMessage") {
|
||||||
let uid = client.channels.getRecipient(channel._id);
|
let uid = client.channels.getRecipient(channel._id);
|
||||||
return <>{prefixType && "@"}{client.users.get(uid)?.username}</>;
|
return (
|
||||||
}
|
<>
|
||||||
|
{prefixType && "@"}
|
||||||
|
{client.users.get(uid)?.username}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (channel.channel_type === "TextChannel" && prefixType) {
|
if (channel.channel_type === "TextChannel" && prefixType) {
|
||||||
return <>#{channel.name}</>;
|
return <>#{channel.name}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{channel.name}</>;
|
return <>{channel.name}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type MessageObject = Omit<Message, "edited"> & { edited?: string };
|
export type MessageObject = Omit<Message, "edited"> & { edited?: string };
|
||||||
export function mapMessage(message: Partial<Message>) {
|
export function mapMessage(message: Partial<Message>) {
|
||||||
const { edited, ...msg } = message;
|
const { edited, ...msg } = message;
|
||||||
return {
|
return {
|
||||||
...msg,
|
...msg,
|
||||||
edited: edited?.$date,
|
edited: edited?.$date,
|
||||||
} as MessageObject;
|
} as MessageObject;
|
||||||
}
|
}
|
||||||
|
|
5
src/env.d.ts
vendored
5
src/env.d.ts
vendored
|
@ -1,5 +1,4 @@
|
||||||
interface ImportMetaEnv {
|
interface ImportMetaEnv {
|
||||||
VITE_API_URL: string;
|
VITE_API_URL: string;
|
||||||
VITE_THEMES_URL: string;
|
VITE_THEMES_URL: string;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,15 +1,16 @@
|
||||||
import { Link, LinkProps } from "react-router-dom";
|
import { Link, LinkProps } from "react-router-dom";
|
||||||
|
|
||||||
type Props = LinkProps & JSX.HTMLAttributes<HTMLAnchorElement> & {
|
type Props = LinkProps &
|
||||||
active: boolean
|
JSX.HTMLAttributes<HTMLAnchorElement> & {
|
||||||
};
|
active: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export default function ConditionalLink(props: Props) {
|
export default function ConditionalLink(props: Props) {
|
||||||
const { active, ...linkProps } = props;
|
const { active, ...linkProps } = props;
|
||||||
|
|
||||||
if (active) {
|
if (active) {
|
||||||
return <a>{ props.children }</a>;
|
return <a>{props.children}</a>;
|
||||||
} else {
|
} else {
|
||||||
return <Link {...linkProps} />;
|
return <Link {...linkProps} />;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -2,17 +2,21 @@ import { useState } from "preact/hooks";
|
||||||
|
|
||||||
const counts: { [key: string]: number } = {};
|
const counts: { [key: string]: number } = {};
|
||||||
|
|
||||||
export default function PaintCounter({ small, always }: { small?: boolean, always?: boolean }) {
|
export default function PaintCounter({
|
||||||
if (import.meta.env.PROD && !always) return null;
|
small,
|
||||||
|
always,
|
||||||
|
}: {
|
||||||
|
small?: boolean;
|
||||||
|
always?: boolean;
|
||||||
|
}) {
|
||||||
|
if (import.meta.env.PROD && !always) return null;
|
||||||
|
|
||||||
const [uniqueId] = useState('' + Math.random());
|
const [uniqueId] = useState("" + Math.random());
|
||||||
const count = counts[uniqueId] ?? 0;
|
const count = counts[uniqueId] ?? 0;
|
||||||
counts[uniqueId] = count + 1;
|
counts[uniqueId] = count + 1;
|
||||||
return (
|
return (
|
||||||
<div style={{ textAlign: 'center', fontSize: '0.8em' }}>
|
<div style={{ textAlign: "center", fontSize: "0.8em" }}>
|
||||||
{ small ? <>P: { count + 1 }</> : <>
|
{small ? <>P: {count + 1}</> : <>Painted {count + 1} time(s).</>}
|
||||||
Painted {count + 1} time(s).
|
</div>
|
||||||
</> }
|
);
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue