Use tabWidth 4 without actual tabs.

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

View File

@@ -20,134 +20,134 @@ import { MessageArea } from "./messaging/MessageArea";
import VoiceHeader from "./voice/VoiceHeader";
const ChannelMain = styled.div`
flex-grow: 1;
display: flex;
min-height: 0;
overflow: hidden;
flex-direction: row;
flex-grow: 1;
display: flex;
min-height: 0;
overflow: hidden;
flex-direction: row;
`;
const ChannelContent = styled.div`
flex-grow: 1;
display: flex;
overflow: hidden;
flex-direction: column;
flex-grow: 1;
display: flex;
overflow: hidden;
flex-direction: column;
`;
const AgeGate = styled.div`
display: flex;
flex-grow: 1;
flex-direction: column;
align-items: center;
justify-content: center;
user-select: none;
padding: 12px;
display: flex;
flex-grow: 1;
flex-direction: column;
align-items: center;
justify-content: center;
user-select: none;
padding: 12px;
img {
height: 150px;
}
img {
height: 150px;
}
.subtext {
color: var(--secondary-foreground);
margin-bottom: 12px;
font-size: 14px;
}
.subtext {
color: var(--secondary-foreground);
margin-bottom: 12px;
font-size: 14px;
}
.actions {
margin-top: 20px;
display: flex;
gap: 12px;
}
.actions {
margin-top: 20px;
display: flex;
gap: 12px;
}
`;
export function Channel({ id }: { id: string }) {
const ctx = useForceUpdate();
const channel = useChannel(id, ctx);
const ctx = useForceUpdate();
const channel = useChannel(id, ctx);
if (!channel) return null;
if (!channel) return null;
if (channel.channel_type === "VoiceChannel") {
return <VoiceChannel channel={channel} />;
} else {
return <TextChannel channel={channel} />;
}
if (channel.channel_type === "VoiceChannel") {
return <VoiceChannel channel={channel} />;
} else {
return <TextChannel channel={channel} />;
}
}
function TextChannel({ channel }: { channel: Channels.Channel }) {
const [showMembers, setMembers] = useState(true);
const [showMembers, setMembers] = useState(true);
if (
(channel.channel_type === "TextChannel" ||
channel.channel_type === "Group") &&
channel.name.includes("nsfw")
) {
const goBack = useHistory();
const [consent, setConsent] = useState(false);
const [ageGate, setAgeGate] = useState(false);
if (!ageGate) {
return (
<AgeGate>
<img
src={"https://static.revolt.chat/emoji/mutant/26a0.svg"}
draggable={false}
/>
<h2>{channel.name}</h2>
<span className="subtext">
This channel is marked as NSFW.{" "}
<a href="#">Learn more</a>
</span>
if (
(channel.channel_type === "TextChannel" ||
channel.channel_type === "Group") &&
channel.name.includes("nsfw")
) {
const goBack = useHistory();
const [consent, setConsent] = useState(false);
const [ageGate, setAgeGate] = useState(false);
if (!ageGate) {
return (
<AgeGate>
<img
src={"https://static.revolt.chat/emoji/mutant/26a0.svg"}
draggable={false}
/>
<h2>{channel.name}</h2>
<span className="subtext">
This channel is marked as NSFW.{" "}
<a href="#">Learn more</a>
</span>
<Checkbox checked={consent} onChange={(v) => setConsent(v)}>
I confirm that I am at least 18 years old.
</Checkbox>
<div className="actions">
<Button contrast onClick={() => goBack}>
Go back
</Button>
<Button
contrast
onClick={() => consent && setAgeGate(true)}>
Enter Channel
</Button>
</div>
</AgeGate>
);
}
}
<Checkbox checked={consent} onChange={(v) => setConsent(v)}>
I confirm that I am at least 18 years old.
</Checkbox>
<div className="actions">
<Button contrast onClick={() => goBack}>
Go back
</Button>
<Button
contrast
onClick={() => consent && setAgeGate(true)}>
Enter Channel
</Button>
</div>
</AgeGate>
);
}
}
let id = channel._id;
return (
<>
<ChannelHeader
channel={channel}
toggleSidebar={() => setMembers(!showMembers)}
/>
<ChannelMain>
<ChannelContent>
<VoiceHeader id={id} />
<MessageArea id={id} />
<TypingIndicator id={id} />
<JumpToBottom id={id} />
<MessageBox channel={channel} />
</ChannelContent>
{!isTouchscreenDevice && showMembers && (
<MemberSidebar channel={channel} />
)}
</ChannelMain>
</>
);
let id = channel._id;
return (
<>
<ChannelHeader
channel={channel}
toggleSidebar={() => setMembers(!showMembers)}
/>
<ChannelMain>
<ChannelContent>
<VoiceHeader id={id} />
<MessageArea id={id} />
<TypingIndicator id={id} />
<JumpToBottom id={id} />
<MessageBox channel={channel} />
</ChannelContent>
{!isTouchscreenDevice && showMembers && (
<MemberSidebar channel={channel} />
)}
</ChannelMain>
</>
);
}
function VoiceChannel({ channel }: { channel: Channels.Channel }) {
return (
<>
<ChannelHeader channel={channel} />
<VoiceHeader id={channel._id} />
</>
);
return (
<>
<ChannelHeader channel={channel} />
<VoiceHeader id={channel._id} />
</>
);
}
export default function () {
const { channel } = useParams<{ channel: string }>();
return <Channel id={channel} key={channel} />;
const { channel } = useParams<{ channel: string }>();
return <Channel id={channel} key={channel} />;
}

View File

@@ -19,121 +19,121 @@ import Markdown from "../../components/markdown/Markdown";
import HeaderActions from "./actions/HeaderActions";
export interface ChannelHeaderProps {
channel: Channel;
toggleSidebar?: () => void;
channel: Channel;
toggleSidebar?: () => void;
}
const Info = styled.div`
flex-grow: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
flex-grow: 1;
min-width: 0;
overflow: hidden;
white-space: nowrap;
display: flex;
gap: 8px;
align-items: center;
display: flex;
gap: 8px;
align-items: center;
* {
display: inline-block;
}
* {
display: inline-block;
}
.divider {
height: 20px;
margin: 0 5px;
padding-left: 1px;
background-color: var(--tertiary-background);
}
.divider {
height: 20px;
margin: 0 5px;
padding-left: 1px;
background-color: var(--tertiary-background);
}
.status {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
margin-inline-end: 6px;
}
.status {
width: 10px;
height: 10px;
border-radius: 50%;
display: inline-block;
margin-inline-end: 6px;
}
.desc {
cursor: pointer;
margin-top: 2px;
font-size: 0.8em;
font-weight: 400;
color: var(--secondary-foreground);
}
.desc {
cursor: pointer;
margin-top: 2px;
font-size: 0.8em;
font-weight: 400;
color: var(--secondary-foreground);
}
`;
export default function ChannelHeader({
channel,
toggleSidebar,
channel,
toggleSidebar,
}: ChannelHeaderProps) {
const { openScreen } = useIntermediate();
const client = useContext(AppContext);
const { openScreen } = useIntermediate();
const client = useContext(AppContext);
const name = getChannelName(client, channel);
let icon, recipient;
switch (channel.channel_type) {
case "SavedMessages":
icon = <Notepad size={24} />;
break;
case "DirectMessage":
icon = <At size={24} />;
const uid = client.channels.getRecipient(channel._id);
recipient = client.users.get(uid);
break;
case "Group":
icon = <Group size={24} />;
break;
case "TextChannel":
icon = <Hash size={24} />;
break;
}
const name = getChannelName(client, channel);
let icon, recipient;
switch (channel.channel_type) {
case "SavedMessages":
icon = <Notepad size={24} />;
break;
case "DirectMessage":
icon = <At size={24} />;
const uid = client.channels.getRecipient(channel._id);
recipient = client.users.get(uid);
break;
case "Group":
icon = <Group size={24} />;
break;
case "TextChannel":
icon = <Hash size={24} />;
break;
}
return (
<Header placement="primary">
{icon}
<Info>
<span className="name">{name}</span>
{isTouchscreenDevice &&
channel.channel_type === "DirectMessage" && (
<>
<div className="divider" />
<span className="desc">
<div
className="status"
style={{
backgroundColor: useStatusColour(
recipient as User,
),
}}
/>
<UserStatus user={recipient as User} />
</span>
</>
)}
{!isTouchscreenDevice &&
(channel.channel_type === "Group" ||
channel.channel_type === "TextChannel") &&
channel.description && (
<>
<div className="divider" />
<span
className="desc"
onClick={() =>
openScreen({
id: "channel_info",
channel_id: channel._id,
})
}>
<Markdown
content={
channel.description.split("\n")[0] ?? ""
}
disallowBigEmoji
/>
</span>
</>
)}
</Info>
<HeaderActions channel={channel} toggleSidebar={toggleSidebar} />
</Header>
);
return (
<Header placement="primary">
{icon}
<Info>
<span className="name">{name}</span>
{isTouchscreenDevice &&
channel.channel_type === "DirectMessage" && (
<>
<div className="divider" />
<span className="desc">
<div
className="status"
style={{
backgroundColor: useStatusColour(
recipient as User,
),
}}
/>
<UserStatus user={recipient as User} />
</span>
</>
)}
{!isTouchscreenDevice &&
(channel.channel_type === "Group" ||
channel.channel_type === "TextChannel") &&
channel.description && (
<>
<div className="divider" />
<span
className="desc"
onClick={() =>
openScreen({
id: "channel_info",
channel_id: channel._id,
})
}>
<Markdown
content={
channel.description.split("\n")[0] ?? ""
}
disallowBigEmoji
/>
</span>
</>
)}
</Info>
<HeaderActions channel={channel} toggleSidebar={toggleSidebar} />
</Header>
);
}

View File

@@ -1,9 +1,9 @@
import { Sidebar as SidebarIcon } from "@styled-icons/boxicons-regular";
import {
UserPlus,
Cog,
PhoneCall,
PhoneOutgoing,
UserPlus,
Cog,
PhoneCall,
PhoneOutgoing,
} from "@styled-icons/boxicons-solid";
import { useHistory } from "react-router-dom";
@@ -12,9 +12,9 @@ import { useContext } from "preact/hooks";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import {
VoiceContext,
VoiceOperationsContext,
VoiceStatus,
VoiceContext,
VoiceOperationsContext,
VoiceStatus,
} from "../../../context/Voice";
import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
@@ -25,88 +25,88 @@ import IconButton from "../../../components/ui/IconButton";
import { ChannelHeaderProps } from "../ChannelHeader";
export default function HeaderActions({
channel,
toggleSidebar,
channel,
toggleSidebar,
}: ChannelHeaderProps) {
const { openScreen } = useIntermediate();
const client = useContext(AppContext);
const history = useHistory();
const { openScreen } = useIntermediate();
const client = useContext(AppContext);
const history = useHistory();
return (
<>
<UpdateIndicator />
{channel.channel_type === "Group" && (
<>
<IconButton
onClick={() =>
openScreen({
id: "user_picker",
omit: channel.recipients,
callback: async (users) => {
for (const user of users) {
await client.channels.addMember(
channel._id,
user,
);
}
},
})
}>
<UserPlus size={27} />
</IconButton>
<IconButton
onClick={() =>
history.push(`/channel/${channel._id}/settings`)
}>
<Cog size={24} />
</IconButton>
</>
)}
<VoiceActions channel={channel} />
{(channel.channel_type === "Group" ||
channel.channel_type === "TextChannel") &&
!isTouchscreenDevice && (
<IconButton onClick={toggleSidebar}>
<SidebarIcon size={22} />
</IconButton>
)}
</>
);
return (
<>
<UpdateIndicator />
{channel.channel_type === "Group" && (
<>
<IconButton
onClick={() =>
openScreen({
id: "user_picker",
omit: channel.recipients,
callback: async (users) => {
for (const user of users) {
await client.channels.addMember(
channel._id,
user,
);
}
},
})
}>
<UserPlus size={27} />
</IconButton>
<IconButton
onClick={() =>
history.push(`/channel/${channel._id}/settings`)
}>
<Cog size={24} />
</IconButton>
</>
)}
<VoiceActions channel={channel} />
{(channel.channel_type === "Group" ||
channel.channel_type === "TextChannel") &&
!isTouchscreenDevice && (
<IconButton onClick={toggleSidebar}>
<SidebarIcon size={22} />
</IconButton>
)}
</>
);
}
function VoiceActions({ channel }: Pick<ChannelHeaderProps, "channel">) {
if (
channel.channel_type === "SavedMessages" ||
channel.channel_type === "TextChannel"
)
return null;
if (
channel.channel_type === "SavedMessages" ||
channel.channel_type === "TextChannel"
)
return null;
const voice = useContext(VoiceContext);
const { connect, disconnect } = useContext(VoiceOperationsContext);
const voice = useContext(VoiceContext);
const { connect, disconnect } = useContext(VoiceOperationsContext);
if (voice.status >= VoiceStatus.READY) {
if (voice.roomId === channel._id) {
return (
<IconButton onClick={disconnect}>
<PhoneOutgoing size={22} />
</IconButton>
);
} else {
return (
<IconButton
onClick={() => {
disconnect();
connect(channel._id);
}}>
<PhoneCall size={24} />
</IconButton>
);
}
} else {
return (
<IconButton>
<PhoneCall size={24} /** ! FIXME: TEMP */ color="red" />
</IconButton>
);
}
if (voice.status >= VoiceStatus.READY) {
if (voice.roomId === channel._id) {
return (
<IconButton onClick={disconnect}>
<PhoneOutgoing size={22} />
</IconButton>
);
} else {
return (
<IconButton
onClick={() => {
disconnect();
connect(channel._id);
}}>
<PhoneCall size={24} />
</IconButton>
);
}
} else {
return (
<IconButton>
<PhoneCall size={24} /** ! FIXME: TEMP */ color="red" />
</IconButton>
);
}
}

View File

@@ -6,35 +6,35 @@ import { useChannel, useForceUpdate } from "../../../context/revoltjs/hooks";
import { getChannelName } from "../../../context/revoltjs/util";
const StartBase = styled.div`
margin: 18px 16px 10px 16px;
margin: 18px 16px 10px 16px;
h1 {
font-size: 23px;
margin: 0 0 8px 0;
}
h1 {
font-size: 23px;
margin: 0 0 8px 0;
}
h4 {
font-weight: 400;
margin: 0;
font-size: 14px;
}
h4 {
font-weight: 400;
margin: 0;
font-size: 14px;
}
`;
interface Props {
id: string;
id: string;
}
export default function ConversationStart({ id }: Props) {
const ctx = useForceUpdate();
const channel = useChannel(id, ctx);
if (!channel) return null;
const ctx = useForceUpdate();
const channel = useChannel(id, ctx);
if (!channel) return null;
return (
<StartBase>
<h1>{getChannelName(ctx.client, channel, true)}</h1>
<h4>
<Text id="app.main.channel.start.group" />
</h4>
</StartBase>
);
return (
<StartBase>
<h1>{getChannelName(ctx.client, channel, true)}</h1>
<h4>
<Text id="app.main.channel.start.group" />
</h4>
</StartBase>
);
}

View File

@@ -4,11 +4,11 @@ import useResizeObserver from "use-resize-observer";
import { createContext } from "preact";
import {
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
useContext,
useEffect,
useLayoutEffect,
useRef,
useState,
} from "preact/hooks";
import { defer } from "../../../lib/defer";
@@ -19,8 +19,8 @@ import { RenderState, ScrollState } from "../../../lib/renderer/types";
import { IntermediateContext } from "../../../context/intermediate/Intermediate";
import RequiresOnline from "../../../context/revoltjs/RequiresOnline";
import {
ClientStatus,
StatusContext,
ClientStatus,
StatusContext,
} from "../../../context/revoltjs/RevoltClient";
import Preloader from "../../../components/ui/Preloader";
@@ -29,231 +29,231 @@ import ConversationStart from "./ConversationStart";
import MessageRenderer from "./MessageRenderer";
const Area = styled.div`
height: 100%;
flex-grow: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: scroll;
word-break: break-word;
height: 100%;
flex-grow: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: scroll;
word-break: break-word;
> div {
display: flex;
min-height: 100%;
padding-bottom: 20px;
flex-direction: column;
justify-content: flex-end;
}
> div {
display: flex;
min-height: 100%;
padding-bottom: 20px;
flex-direction: column;
justify-content: flex-end;
}
`;
interface Props {
id: string;
id: string;
}
export const MessageAreaWidthContext = createContext(0);
export const MESSAGE_AREA_PADDING = 82;
export function MessageArea({ id }: Props) {
const status = useContext(StatusContext);
const { focusTaken } = useContext(IntermediateContext);
const status = useContext(StatusContext);
const { focusTaken } = useContext(IntermediateContext);
// ? This is the scroll container.
const ref = useRef<HTMLDivElement>(null);
const { width, height } = useResizeObserver<HTMLDivElement>({ ref });
// ? This is the scroll container.
const ref = useRef<HTMLDivElement>(null);
const { width, height } = useResizeObserver<HTMLDivElement>({ ref });
// ? Current channel state.
const [state, setState] = useState<RenderState>({ type: "LOADING" });
// ? Current channel state.
const [state, setState] = useState<RenderState>({ type: "LOADING" });
// ? useRef to avoid re-renders
const scrollState = useRef<ScrollState>({ type: "Free" });
// ? useRef to avoid re-renders
const scrollState = useRef<ScrollState>({ type: "Free" });
const setScrollState = (v: ScrollState) => {
if (v.type === "StayAtBottom") {
if (scrollState.current.type === "Bottom" || atBottom()) {
scrollState.current = {
type: "ScrollToBottom",
smooth: v.smooth,
};
} else {
scrollState.current = { type: "Free" };
}
} else {
scrollState.current = v;
}
const setScrollState = (v: ScrollState) => {
if (v.type === "StayAtBottom") {
if (scrollState.current.type === "Bottom" || atBottom()) {
scrollState.current = {
type: "ScrollToBottom",
smooth: v.smooth,
};
} else {
scrollState.current = { type: "Free" };
}
} else {
scrollState.current = v;
}
defer(() => {
if (scrollState.current.type === "ScrollToBottom") {
setScrollState({
type: "Bottom",
scrollingUntil: +new Date() + 150,
});
defer(() => {
if (scrollState.current.type === "ScrollToBottom") {
setScrollState({
type: "Bottom",
scrollingUntil: +new Date() + 150,
});
animateScroll.scrollToBottom({
container: ref.current,
duration: scrollState.current.smooth ? 150 : 0,
});
} else if (scrollState.current.type === "OffsetTop") {
animateScroll.scrollTo(
Math.max(
101,
ref.current.scrollTop +
(ref.current.scrollHeight -
scrollState.current.previousHeight),
),
{
container: ref.current,
duration: 0,
},
);
animateScroll.scrollToBottom({
container: ref.current,
duration: scrollState.current.smooth ? 150 : 0,
});
} else if (scrollState.current.type === "OffsetTop") {
animateScroll.scrollTo(
Math.max(
101,
ref.current.scrollTop +
(ref.current.scrollHeight -
scrollState.current.previousHeight),
),
{
container: ref.current,
duration: 0,
},
);
setScrollState({ type: "Free" });
} else if (scrollState.current.type === "ScrollTop") {
animateScroll.scrollTo(scrollState.current.y, {
container: ref.current,
duration: 0,
});
setScrollState({ type: "Free" });
} else if (scrollState.current.type === "ScrollTop") {
animateScroll.scrollTo(scrollState.current.y, {
container: ref.current,
duration: 0,
});
setScrollState({ type: "Free" });
}
});
};
setScrollState({ type: "Free" });
}
});
};
// ? Determine if we are at the bottom of the scroll container.
// -> https://stackoverflow.com/a/44893438
// By default, we assume we are at the bottom, i.e. when we first load.
const atBottom = (offset = 0) =>
ref.current
? Math.floor(ref.current.scrollHeight - ref.current.scrollTop) -
offset <=
ref.current.clientHeight
: true;
// ? Determine if we are at the bottom of the scroll container.
// -> https://stackoverflow.com/a/44893438
// By default, we assume we are at the bottom, i.e. when we first load.
const atBottom = (offset = 0) =>
ref.current
? Math.floor(ref.current.scrollHeight - ref.current.scrollTop) -
offset <=
ref.current.clientHeight
: true;
const atTop = (offset = 0) => ref.current.scrollTop <= offset;
const atTop = (offset = 0) => ref.current.scrollTop <= offset;
// ? Handle events from renderer.
useEffect(() => {
SingletonMessageRenderer.addListener("state", setState);
return () => SingletonMessageRenderer.removeListener("state", setState);
}, []);
// ? Handle events from renderer.
useEffect(() => {
SingletonMessageRenderer.addListener("state", setState);
return () => SingletonMessageRenderer.removeListener("state", setState);
}, []);
useEffect(() => {
SingletonMessageRenderer.addListener("scroll", setScrollState);
return () =>
SingletonMessageRenderer.removeListener("scroll", setScrollState);
}, [scrollState]);
useEffect(() => {
SingletonMessageRenderer.addListener("scroll", setScrollState);
return () =>
SingletonMessageRenderer.removeListener("scroll", setScrollState);
}, [scrollState]);
// ? Load channel initially.
useEffect(() => {
SingletonMessageRenderer.init(id);
}, [id]);
// ? Load channel initially.
useEffect(() => {
SingletonMessageRenderer.init(id);
}, [id]);
// ? If we are waiting for network, try again.
useEffect(() => {
switch (status) {
case ClientStatus.ONLINE:
if (state.type === "WAITING_FOR_NETWORK") {
SingletonMessageRenderer.init(id);
} else {
SingletonMessageRenderer.reloadStale(id);
}
// ? If we are waiting for network, try again.
useEffect(() => {
switch (status) {
case ClientStatus.ONLINE:
if (state.type === "WAITING_FOR_NETWORK") {
SingletonMessageRenderer.init(id);
} else {
SingletonMessageRenderer.reloadStale(id);
}
break;
case ClientStatus.OFFLINE:
case ClientStatus.DISCONNECTED:
case ClientStatus.CONNECTING:
SingletonMessageRenderer.markStale();
break;
}
}, [status, state]);
break;
case ClientStatus.OFFLINE:
case ClientStatus.DISCONNECTED:
case ClientStatus.CONNECTING:
SingletonMessageRenderer.markStale();
break;
}
}, [status, state]);
// ? When the container is scrolled.
// ? Also handle StayAtBottom
useEffect(() => {
async function onScroll() {
if (scrollState.current.type === "Free" && atBottom()) {
setScrollState({ type: "Bottom" });
} else if (scrollState.current.type === "Bottom" && !atBottom()) {
if (
scrollState.current.scrollingUntil &&
scrollState.current.scrollingUntil > +new Date()
)
return;
setScrollState({ type: "Free" });
}
}
// ? When the container is scrolled.
// ? Also handle StayAtBottom
useEffect(() => {
async function onScroll() {
if (scrollState.current.type === "Free" && atBottom()) {
setScrollState({ type: "Bottom" });
} else if (scrollState.current.type === "Bottom" && !atBottom()) {
if (
scrollState.current.scrollingUntil &&
scrollState.current.scrollingUntil > +new Date()
)
return;
setScrollState({ type: "Free" });
}
}
ref.current.addEventListener("scroll", onScroll);
return () => ref.current.removeEventListener("scroll", onScroll);
}, [ref, scrollState]);
ref.current.addEventListener("scroll", onScroll);
return () => ref.current.removeEventListener("scroll", onScroll);
}, [ref, scrollState]);
// ? Top and bottom loaders.
useEffect(() => {
async function onScroll() {
if (atTop(100)) {
SingletonMessageRenderer.loadTop(ref.current);
}
// ? Top and bottom loaders.
useEffect(() => {
async function onScroll() {
if (atTop(100)) {
SingletonMessageRenderer.loadTop(ref.current);
}
if (atBottom(100)) {
SingletonMessageRenderer.loadBottom(ref.current);
}
}
if (atBottom(100)) {
SingletonMessageRenderer.loadBottom(ref.current);
}
}
ref.current.addEventListener("scroll", onScroll);
return () => ref.current.removeEventListener("scroll", onScroll);
}, [ref]);
ref.current.addEventListener("scroll", onScroll);
return () => ref.current.removeEventListener("scroll", onScroll);
}, [ref]);
// ? Scroll down whenever the message area resizes.
function stbOnResize() {
if (!atBottom() && scrollState.current.type === "Bottom") {
animateScroll.scrollToBottom({
container: ref.current,
duration: 0,
});
// ? Scroll down whenever the message area resizes.
function stbOnResize() {
if (!atBottom() && scrollState.current.type === "Bottom") {
animateScroll.scrollToBottom({
container: ref.current,
duration: 0,
});
setScrollState({ type: "Bottom" });
}
}
setScrollState({ type: "Bottom" });
}
}
// ? Scroll down when container resized.
useLayoutEffect(() => {
stbOnResize();
}, [height]);
// ? Scroll down when container resized.
useLayoutEffect(() => {
stbOnResize();
}, [height]);
// ? Scroll down whenever the window resizes.
useLayoutEffect(() => {
document.addEventListener("resize", stbOnResize);
return () => document.removeEventListener("resize", stbOnResize);
}, [ref, scrollState]);
// ? Scroll down whenever the window resizes.
useLayoutEffect(() => {
document.addEventListener("resize", stbOnResize);
return () => document.removeEventListener("resize", stbOnResize);
}, [ref, scrollState]);
// ? Scroll to bottom when pressing 'Escape'.
useEffect(() => {
function keyUp(e: KeyboardEvent) {
if (e.key === "Escape" && !focusTaken) {
SingletonMessageRenderer.jumpToBottom(id, true);
internalEmit("TextArea", "focus", "message");
}
}
// ? Scroll to bottom when pressing 'Escape'.
useEffect(() => {
function keyUp(e: KeyboardEvent) {
if (e.key === "Escape" && !focusTaken) {
SingletonMessageRenderer.jumpToBottom(id, true);
internalEmit("TextArea", "focus", "message");
}
}
document.body.addEventListener("keyup", keyUp);
return () => document.body.removeEventListener("keyup", keyUp);
}, [ref, focusTaken]);
document.body.addEventListener("keyup", keyUp);
return () => document.body.removeEventListener("keyup", keyUp);
}, [ref, focusTaken]);
return (
<MessageAreaWidthContext.Provider
value={(width ?? 0) - MESSAGE_AREA_PADDING}>
<Area ref={ref}>
<div>
{state.type === "LOADING" && <Preloader type="ring" />}
{state.type === "WAITING_FOR_NETWORK" && (
<RequiresOnline>
<Preloader type="ring" />
</RequiresOnline>
)}
{state.type === "RENDER" && (
<MessageRenderer id={id} state={state} />
)}
{state.type === "EMPTY" && <ConversationStart id={id} />}
</div>
</Area>
</MessageAreaWidthContext.Provider>
);
return (
<MessageAreaWidthContext.Provider
value={(width ?? 0) - MESSAGE_AREA_PADDING}>
<Area ref={ref}>
<div>
{state.type === "LOADING" && <Preloader type="ring" />}
{state.type === "WAITING_FOR_NETWORK" && (
<RequiresOnline>
<Preloader type="ring" />
</RequiresOnline>
)}
{state.type === "RENDER" && (
<MessageRenderer id={id} state={state} />
)}
{state.type === "EMPTY" && <ConversationStart id={id} />}
</div>
</Area>
</MessageAreaWidthContext.Provider>
);
}

View File

@@ -6,128 +6,128 @@ import TextAreaAutoSize from "../../../lib/TextAreaAutoSize";
import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import {
IntermediateContext,
useIntermediate,
IntermediateContext,
useIntermediate,
} from "../../../context/intermediate/Intermediate";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { MessageObject } from "../../../context/revoltjs/util";
import AutoComplete, {
useAutoComplete,
useAutoComplete,
} from "../../../components/common/AutoComplete";
const EditorBase = styled.div`
display: flex;
flex-direction: column;
display: flex;
flex-direction: column;
textarea {
resize: none;
padding: 12px;
font-size: 0.875rem;
border-radius: 3px;
white-space: pre-wrap;
background: var(--secondary-header);
}
textarea {
resize: none;
padding: 12px;
font-size: 0.875rem;
border-radius: 3px;
white-space: pre-wrap;
background: var(--secondary-header);
}
.caption {
padding: 2px;
font-size: 11px;
color: var(--tertiary-foreground);
.caption {
padding: 2px;
font-size: 11px;
color: var(--tertiary-foreground);
a {
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
a {
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
`;
interface Props {
message: MessageObject;
finish: () => void;
message: MessageObject;
finish: () => void;
}
export default function MessageEditor({ message, finish }: Props) {
const [content, setContent] = useState((message.content as string) ?? "");
const { focusTaken } = useContext(IntermediateContext);
const { openScreen } = useIntermediate();
const client = useContext(AppContext);
const [content, setContent] = useState((message.content as string) ?? "");
const { focusTaken } = useContext(IntermediateContext);
const { openScreen } = useIntermediate();
const client = useContext(AppContext);
async function save() {
finish();
async function save() {
finish();
if (content.length === 0) {
openScreen({
id: "special_prompt",
// @ts-expect-error
type: "delete_message",
// @ts-expect-error
target: message,
});
} else if (content !== message.content) {
await client.channels.editMessage(message.channel, message._id, {
content,
});
}
}
if (content.length === 0) {
openScreen({
id: "special_prompt",
// @ts-expect-error
type: "delete_message",
// @ts-expect-error
target: message,
});
} else if (content !== message.content) {
await client.channels.editMessage(message.channel, message._id, {
content,
});
}
}
// ? Stop editing when pressing ESC.
useEffect(() => {
function keyUp(e: KeyboardEvent) {
if (e.key === "Escape" && !focusTaken) {
finish();
}
}
// ? Stop editing when pressing ESC.
useEffect(() => {
function keyUp(e: KeyboardEvent) {
if (e.key === "Escape" && !focusTaken) {
finish();
}
}
document.body.addEventListener("keyup", keyUp);
return () => document.body.removeEventListener("keyup", keyUp);
}, [focusTaken]);
document.body.addEventListener("keyup", keyUp);
return () => document.body.removeEventListener("keyup", keyUp);
}, [focusTaken]);
const {
onChange,
onKeyUp,
onKeyDown,
onFocus,
onBlur,
...autoCompleteProps
} = useAutoComplete((v) => setContent(v ?? ""), {
users: { type: "all" },
});
const {
onChange,
onKeyUp,
onKeyDown,
onFocus,
onBlur,
...autoCompleteProps
} = useAutoComplete((v) => setContent(v ?? ""), {
users: { type: "all" },
});
return (
<EditorBase>
<AutoComplete detached {...autoCompleteProps} />
<TextAreaAutoSize
forceFocus
maxRows={3}
padding={12}
value={content}
maxLength={2000}
onChange={(ev) => {
onChange(ev);
setContent(ev.currentTarget.value);
}}
onKeyDown={(e) => {
if (onKeyDown(e)) return;
return (
<EditorBase>
<AutoComplete detached {...autoCompleteProps} />
<TextAreaAutoSize
forceFocus
maxRows={3}
padding={12}
value={content}
maxLength={2000}
onChange={(ev) => {
onChange(ev);
setContent(ev.currentTarget.value);
}}
onKeyDown={(e) => {
if (onKeyDown(e)) return;
if (
!e.shiftKey &&
e.key === "Enter" &&
!isTouchscreenDevice
) {
e.preventDefault();
save();
}
}}
onKeyUp={onKeyUp}
onFocus={onFocus}
onBlur={onBlur}
/>
<span className="caption">
escape to <a onClick={finish}>cancel</a> &middot; enter to{" "}
<a onClick={save}>save</a>
</span>
</EditorBase>
);
if (
!e.shiftKey &&
e.key === "Enter" &&
!isTouchscreenDevice
) {
e.preventDefault();
save();
}
}}
onKeyUp={onKeyUp}
onFocus={onFocus}
onBlur={onBlur}
/>
<span className="caption">
escape to <a onClick={finish}>cancel</a> &middot; enter to{" "}
<a onClick={save}>save</a>
</span>
</EditorBase>
);
}

View File

@@ -26,190 +26,190 @@ import ConversationStart from "./ConversationStart";
import MessageEditor from "./MessageEditor";
interface Props {
id: string;
state: RenderState;
queue: QueuedMessage[];
id: string;
state: RenderState;
queue: QueuedMessage[];
}
const BlockedMessage = styled.div`
font-size: 0.8em;
margin-top: 6px;
padding: 4px 64px;
color: var(--tertiary-foreground);
font-size: 0.8em;
margin-top: 6px;
padding: 4px 64px;
color: var(--tertiary-foreground);
&:hover {
background: var(--hover);
}
&:hover {
background: var(--hover);
}
`;
function MessageRenderer({ id, state, queue }: Props) {
if (state.type !== "RENDER") return null;
if (state.type !== "RENDER") return null;
const client = useContext(AppContext);
const userId = client.user!._id;
const client = useContext(AppContext);
const userId = client.user!._id;
const [editing, setEditing] = useState<string | undefined>(undefined);
const stopEditing = () => {
setEditing(undefined);
internalEmit("TextArea", "focus", "message");
};
const [editing, setEditing] = useState<string | undefined>(undefined);
const stopEditing = () => {
setEditing(undefined);
internalEmit("TextArea", "focus", "message");
};
useEffect(() => {
function editLast() {
if (state.type !== "RENDER") return;
for (let i = state.messages.length - 1; i >= 0; i--) {
if (state.messages[i].author === userId) {
setEditing(state.messages[i]._id);
return;
}
}
}
useEffect(() => {
function editLast() {
if (state.type !== "RENDER") return;
for (let i = state.messages.length - 1; i >= 0; i--) {
if (state.messages[i].author === userId) {
setEditing(state.messages[i]._id);
return;
}
}
}
const subs = [
internalSubscribe("MessageRenderer", "edit_last", editLast),
internalSubscribe("MessageRenderer", "edit_message", setEditing),
];
const subs = [
internalSubscribe("MessageRenderer", "edit_last", editLast),
internalSubscribe("MessageRenderer", "edit_message", setEditing),
];
return () => subs.forEach((unsub) => unsub());
}, [state.messages]);
return () => subs.forEach((unsub) => unsub());
}, [state.messages]);
let render: Children[] = [],
previous: MessageObject | undefined;
let render: Children[] = [],
previous: MessageObject | undefined;
if (state.atTop) {
render.push(<ConversationStart id={id} />);
} else {
render.push(
<RequiresOnline>
<Preloader type="ring" />
</RequiresOnline>,
);
}
if (state.atTop) {
render.push(<ConversationStart id={id} />);
} else {
render.push(
<RequiresOnline>
<Preloader type="ring" />
</RequiresOnline>,
);
}
let head = true;
function compare(
current: string,
curAuthor: string,
previous: string,
prevAuthor: string,
) {
const atime = decodeTime(current),
adate = new Date(atime),
btime = decodeTime(previous),
bdate = new Date(btime);
let head = true;
function compare(
current: string,
curAuthor: string,
previous: string,
prevAuthor: string,
) {
const atime = decodeTime(current),
adate = new Date(atime),
btime = decodeTime(previous),
bdate = new Date(btime);
if (
adate.getFullYear() !== bdate.getFullYear() ||
adate.getMonth() !== bdate.getMonth() ||
adate.getDate() !== bdate.getDate()
) {
render.push(<DateDivider date={adate} />);
head = true;
}
if (
adate.getFullYear() !== bdate.getFullYear() ||
adate.getMonth() !== bdate.getMonth() ||
adate.getDate() !== bdate.getDate()
) {
render.push(<DateDivider date={adate} />);
head = true;
}
head = curAuthor !== prevAuthor || Math.abs(btime - atime) >= 420000;
}
head = curAuthor !== prevAuthor || Math.abs(btime - atime) >= 420000;
}
let blocked = 0;
function pushBlocked() {
render.push(
<BlockedMessage>
<X size={16} /> {blocked} blocked messages
</BlockedMessage>,
);
blocked = 0;
}
let blocked = 0;
function pushBlocked() {
render.push(
<BlockedMessage>
<X size={16} /> {blocked} blocked messages
</BlockedMessage>,
);
blocked = 0;
}
for (const message of state.messages) {
if (previous) {
compare(message._id, message.author, previous._id, previous.author);
}
for (const message of state.messages) {
if (previous) {
compare(message._id, message.author, previous._id, previous.author);
}
if (message.author === "00000000000000000000000000") {
render.push(
<SystemMessage
key={message._id}
message={message}
attachContext
/>,
);
} else {
// ! FIXME: temp solution
if (
client.users.get(message.author)?.relationship ===
Users.Relationship.Blocked
) {
blocked++;
} else {
if (blocked > 0) pushBlocked();
if (message.author === "00000000000000000000000000") {
render.push(
<SystemMessage
key={message._id}
message={message}
attachContext
/>,
);
} else {
// ! FIXME: temp solution
if (
client.users.get(message.author)?.relationship ===
Users.Relationship.Blocked
) {
blocked++;
} else {
if (blocked > 0) pushBlocked();
render.push(
<Message
message={message}
key={message._id}
head={head}
content={
editing === message._id ? (
<MessageEditor
message={message}
finish={stopEditing}
/>
) : undefined
}
attachContext
/>,
);
}
}
render.push(
<Message
message={message}
key={message._id}
head={head}
content={
editing === message._id ? (
<MessageEditor
message={message}
finish={stopEditing}
/>
) : undefined
}
attachContext
/>,
);
}
}
previous = message;
}
previous = message;
}
if (blocked > 0) pushBlocked();
if (blocked > 0) pushBlocked();
const nonces = state.messages.map((x) => x.nonce);
if (state.atBottom) {
for (const msg of queue) {
if (msg.channel !== id) continue;
if (nonces.includes(msg.id)) continue;
const nonces = state.messages.map((x) => x.nonce);
if (state.atBottom) {
for (const msg of queue) {
if (msg.channel !== id) continue;
if (nonces.includes(msg.id)) continue;
if (previous) {
compare(msg.id, userId!, previous._id, previous.author);
if (previous) {
compare(msg.id, userId!, previous._id, previous.author);
previous = {
_id: msg.id,
data: { author: userId! },
} as any;
}
previous = {
_id: msg.id,
data: { author: userId! },
} as any;
}
render.push(
<Message
message={{
...msg.data,
replies: msg.data.replies.map((x) => x.id),
}}
key={msg.id}
queued={msg}
head={head}
attachContext
/>,
);
}
} else {
render.push(
<RequiresOnline>
<Preloader type="ring" />
</RequiresOnline>,
);
}
render.push(
<Message
message={{
...msg.data,
replies: msg.data.replies.map((x) => x.id),
}}
key={msg.id}
queued={msg}
head={head}
attachContext
/>,
);
}
} else {
render.push(
<RequiresOnline>
<Preloader type="ring" />
</RequiresOnline>,
);
}
return <>{render}</>;
return <>{render}</>;
}
export default memo(
connectState<Omit<Props, "queue">>(MessageRenderer, (state) => {
return {
queue: state.queue,
};
}),
connectState<Omit<Props, "queue">>(MessageRenderer, (state) => {
return {
queue: state.queue,
};
}),
);

View File

@@ -5,134 +5,134 @@ import { Text } from "preact-i18n";
import { useContext } from "preact/hooks";
import {
VoiceContext,
VoiceOperationsContext,
VoiceStatus,
VoiceContext,
VoiceOperationsContext,
VoiceStatus,
} from "../../../context/Voice";
import {
useForceUpdate,
useSelf,
useUsers,
useForceUpdate,
useSelf,
useUsers,
} from "../../../context/revoltjs/hooks";
import UserIcon from "../../../components/common/user/UserIcon";
import Button from "../../../components/ui/Button";
interface Props {
id: string;
id: string;
}
const VoiceBase = styled.div`
padding: 20px;
background: var(--secondary-background);
padding: 20px;
background: var(--secondary-background);
.status {
position: absolute;
color: var(--success);
background: var(--primary-background);
display: flex;
align-items: center;
padding: 10px;
font-size: 14px;
font-weight: 600;
border-radius: 7px;
flex: 1 0;
user-select: none;
.status {
position: absolute;
color: var(--success);
background: var(--primary-background);
display: flex;
align-items: center;
padding: 10px;
font-size: 14px;
font-weight: 600;
border-radius: 7px;
flex: 1 0;
user-select: none;
svg {
margin-inline-end: 4px;
cursor: help;
}
}
svg {
margin-inline-end: 4px;
cursor: help;
}
}
display: flex;
flex-direction: column;
display: flex;
flex-direction: column;
.participants {
margin: 20px 0;
justify-content: center;
pointer-events: none;
user-select: none;
display: flex;
gap: 16px;
.participants {
margin: 20px 0;
justify-content: center;
pointer-events: none;
user-select: none;
display: flex;
gap: 16px;
.disconnected {
opacity: 0.5;
}
}
.disconnected {
opacity: 0.5;
}
}
.actions {
display: flex;
justify-content: center;
gap: 10px;
}
.actions {
display: flex;
justify-content: center;
gap: 10px;
}
`;
export default function VoiceHeader({ id }: Props) {
const { status, participants, roomId } = useContext(VoiceContext);
if (roomId !== id) return null;
const { status, participants, roomId } = useContext(VoiceContext);
if (roomId !== id) return null;
const { isProducing, startProducing, stopProducing, disconnect } =
useContext(VoiceOperationsContext);
const { isProducing, startProducing, stopProducing, disconnect } =
useContext(VoiceOperationsContext);
const ctx = useForceUpdate();
const self = useSelf(ctx);
const keys = participants ? Array.from(participants.keys()) : undefined;
const users = keys ? useUsers(keys, ctx) : undefined;
const ctx = useForceUpdate();
const self = useSelf(ctx);
const keys = participants ? Array.from(participants.keys()) : undefined;
const users = keys ? useUsers(keys, ctx) : undefined;
return (
<VoiceBase>
<div className="participants">
{users && users.length !== 0
? users.map((user, index) => {
const id = keys![index];
return (
<div key={id}>
<UserIcon
size={80}
target={user}
status={false}
voice={
participants!.get(id)?.audio
? undefined
: "muted"
}
/>
</div>
);
})
: self !== undefined && (
<div key={self._id} className="disconnected">
<UserIcon
size={80}
target={self}
status={false}
/>
</div>
)}
</div>
<div className="status">
<BarChart size={20} />
{status === VoiceStatus.CONNECTED && (
<Text id="app.main.channel.voice.connected" />
)}
</div>
<div className="actions">
<Button error onClick={disconnect}>
<Text id="app.main.channel.voice.leave" />
</Button>
{isProducing("audio") ? (
<Button onClick={() => stopProducing("audio")}>
<Text id="app.main.channel.voice.mute" />
</Button>
) : (
<Button onClick={() => startProducing("audio")}>
<Text id="app.main.channel.voice.unmute" />
</Button>
)}
</div>
</VoiceBase>
);
return (
<VoiceBase>
<div className="participants">
{users && users.length !== 0
? users.map((user, index) => {
const id = keys![index];
return (
<div key={id}>
<UserIcon
size={80}
target={user}
status={false}
voice={
participants!.get(id)?.audio
? undefined
: "muted"
}
/>
</div>
);
})
: self !== undefined && (
<div key={self._id} className="disconnected">
<UserIcon
size={80}
target={self}
status={false}
/>
</div>
)}
</div>
<div className="status">
<BarChart size={20} />
{status === VoiceStatus.CONNECTED && (
<Text id="app.main.channel.voice.connected" />
)}
</div>
<div className="actions">
<Button error onClick={disconnect}>
<Text id="app.main.channel.voice.leave" />
</Button>
{isProducing("audio") ? (
<Button onClick={() => stopProducing("audio")}>
<Text id="app.main.channel.voice.mute" />
</Button>
) : (
<Button onClick={() => startProducing("audio")}>
<Text id="app.main.channel.voice.unmute" />
</Button>
)}
</div>
</VoiceBase>
);
}
/**{voice.roomId === id && (