1
0
mirror of https://github.com/spacebarchat/client.git synced 2024-11-22 10:22:30 +01:00

rework message listing

This commit is contained in:
Puyodead1 2023-08-25 22:10:50 -04:00
parent 351f3eca92
commit 662a49024b
No known key found for this signature in database
GPG Key ID: A4FA4FEC0DD353FC
17 changed files with 325 additions and 207 deletions

View File

@ -1,127 +0,0 @@
import { observer } from "mobx-react-lite";
import React from "react";
import InfiniteScroll from "react-infinite-scroll-component";
import { useParams } from "react-router-dom";
import styled from "styled-components";
import useLogger from "../hooks/useLogger";
import { useAppStore } from "../stores/AppStore";
import { QueuedMessageStatus } from "../stores/MessageQueue";
import ChatHeader from "./ChatHeader";
import Message from "./Message";
import MessageInput from "./MessageInput";
const Wrapper = styled.div`
display: flex;
flex-direction: column;
flex: 1 1 100%;
background-color: var(--background-primary-alt);
`;
const MessageListWrapper = styled.div`
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column-reverse;
`;
const Container = styled.div`
display: flex;
flex-direction: column;
flex: 1 1 auto;
overflow: hidden;
position: relative;
`;
const Spacer = styled.div`
height: 30px;
width: 1px;
`;
function Chat() {
const app = useAppStore();
const logger = useLogger("Chat");
const { guildId, channelId } = useParams<{
guildId: string;
channelId: string;
}>();
const guild = app.guilds.get(guildId!);
const channel = guild?.channels.get(channelId!);
React.useEffect(() => {
if (guild && channel) {
channel.getMessages(app, true);
}
}, [guild, channel]);
if (!guild || !channel) {
return (
<Wrapper>
<ChatHeader channel={channel} />
<span>{!guild ? "Unknown Guild" : "Unknown Channel"}</span>
</Wrapper>
);
}
const messages = [...(channel.messages.messages ?? []), ...(channel ? app.queue.get(channel.id) ?? [] : [])];
const fetchMore = async () => {
if (!channel.messages.count) {
return;
}
// get first message in the list to use as before
const before = channel.messages.messages[0].id;
logger.debug(`Fetching 50 messages before ${before} for channel ${channel.id}`);
await channel.getMessages(app, false, 50, before);
};
return (
<Wrapper>
<ChatHeader channel={channel} />
<Container>
<MessageListWrapper>
<MessageListWrapper id="scrollable-div">
<InfiniteScroll
dataLength={messages.length}
next={fetchMore}
inverse={true}
// TODO: change this to false when we have a fetch that returns less than 50 messages
hasMore={true}
loader={<h4>Loading...</h4>}
scrollableTarget="scrollable-div"
>
{messages.map((message, index, arr) => {
// calculate max ms between messages to determine if they should be grouped (if from same author). 7 minutes
const maxTimeDifference = 1000 * 60 * 7;
const isHeader =
// always show header for first message
index === 0 ||
// show header if author is different from previous message
message.author.id !== arr[index - 1].author.id ||
// show header if time difference is greater than maxTimeDifference
message.timestamp.getTime() - arr[index - 1].timestamp.getTime() >
maxTimeDifference;
return (
<Message
key={message.id}
message={message}
isHeader={isHeader}
isSending={
"status" in message && message.status === QueuedMessageStatus.SENDING
}
isFailed={"status" in message && message.status === QueuedMessageStatus.FAILED}
/>
);
})}
<Spacer />
</InfiniteScroll>
</MessageListWrapper>
</MessageListWrapper>
<MessageInput channel={channel} />
</Container>
</Wrapper>
);
}
export default observer(Chat);

View File

@ -1,5 +1,17 @@
import styled from "styled-components";
export const Divider = styled.span`
export const HorizontalDivider = styled.div`
width: 100%;
margin-top: 24px;
z-index: 1;
height: 0;
border-top: thin solid var(--text-disabled);
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
`;
export const TextDivider = styled.span`
padding: 0 4px;
`;

View File

@ -1,12 +1,12 @@
import React from "react";
import Moment from "react-moment";
import styled from "styled-components";
import { ContextMenuContext } from "../contexts/ContextMenuContext";
import { QueuedMessage } from "../stores/MessageQueue";
import { default as MessageObject } from "../stores/objects/Message";
import { calendarStrings } from "../utils/i18n";
import Avatar from "./Avatar";
import { IContextMenuItem } from "./ContextMenuItem";
import { ContextMenuContext } from "../../contexts/ContextMenuContext";
import { QueuedMessage } from "../../stores/MessageQueue";
import { default as MessageObject } from "../../stores/objects/Message";
import { calendarStrings } from "../../utils/i18n";
import Avatar from "../Avatar";
import { IContextMenuItem } from "./../ContextMenuItem";
type MessageLike = MessageObject | QueuedMessage;
@ -17,7 +17,6 @@ const Container = styled.div<{ isHeader?: boolean }>`
flex-direction: row;
position: relative;
padding: ${(props) => (props.isHeader ? "4" : "2")}px 12px;
margin-top: ${(props) => (props.isHeader ? "20px" : undefined)};
&:hover {
background-color: var(--background-primary-highlight);
@ -60,6 +59,9 @@ interface Props {
isFailed?: boolean;
}
/**
* Component for rendering a single message
*/
function Message({ message, isHeader, isSending, isFailed }: Props) {
const contextMenu = React.useContext(ContextMenuContext);
const [contextMenuItems, setContextMenuItems] = React.useState<IContextMenuItem[]>([

View File

@ -0,0 +1,35 @@
import styled from "styled-components";
import { QueuedMessageStatus } from "../../stores/MessageQueue";
import { default as MessageObject } from "../../stores/objects/Message";
import Message from "./Message";
const Container = styled.div`
margin-top: 20px;
`;
interface Props {
messages: MessageObject[];
}
/**
* Component that handles rendering a group of messages from the same author
*/
function MessageGroup({ messages }: Props) {
return (
<Container>
{messages.map((message, index) => {
return (
<Message
key={message.id}
message={message}
isHeader={index === 0}
isSending={"status" in message && message.status === QueuedMessageStatus.SENDING}
isFailed={"status" in message && message.status === QueuedMessageStatus.FAILED}
/>
);
})}
</Container>
);
}
export default MessageGroup;

View File

@ -1,14 +1,14 @@
import styled from "styled-components";
import useLogger from "../hooks/useLogger";
import { useAppStore } from "../stores/AppStore";
import Channel from "../stores/objects/Channel";
import useLogger from "../../hooks/useLogger";
import { useAppStore } from "../../stores/AppStore";
import Channel from "../../stores/objects/Channel";
import { useMemo, useState } from "react";
import { BaseEditor, Descendant, Node, createEditor } from "slate";
import { HistoryEditor, withHistory } from "slate-history";
import { Editable, ReactEditor, Slate, withReact } from "slate-react";
import User from "../stores/objects/User";
import Snowflake from "../utils/Snowflake";
import User from "../../stores/objects/User";
import Snowflake from "../../utils/Snowflake";
type CustomElement = { type: "paragraph"; children: CustomText[] };
type CustomText = { text: string; bold?: true };
@ -50,6 +50,9 @@ interface Props {
channel?: Channel;
}
/**
* Component for sending messages
*/
function MessageInput(props: Props) {
const app = useAppStore();
const logger = useLogger("MessageInput");

View File

@ -0,0 +1,95 @@
import { observer } from "mobx-react-lite";
import React from "react";
import InfiniteScroll from "react-infinite-scroll-component";
import { PulseLoader } from "react-spinners";
import styled from "styled-components";
import useLogger from "../../hooks/useLogger";
import { useAppStore } from "../../stores/AppStore";
import Channel from "../../stores/objects/Channel";
import Guild from "../../stores/objects/Guild";
import { HorizontalDivider } from "../Divider";
import MessageGroup from "./MessageGroup";
const Container = styled.div`
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column-reverse;
`;
const EndMessageContainer = styled.div`
margin: 16px 16px 0 16px;
`;
interface Props {
guild: Guild;
channel: Channel;
}
/**
* Main component for rendering the messages list of a channel
*/
function MessageList({ guild, channel }: Props) {
const app = useAppStore();
const logger = useLogger("MessageList.tsx");
// const messages = [...(channel.messages.messages ?? []), ...(channel ? app.queue.get(channel.id) ?? [] : [])];
const [hasMore, setHasMore] = React.useState(true);
// handles the initial fetch of channel messages
React.useEffect(() => {
if (guild && channel && channel.messages.count === 0) {
channel.getMessages(app, true).then((r) => {
if (r < 50) {
setHasMore(false);
}
});
}
}, [guild, channel]);
const fetchMore = async () => {
if (!channel.messages.count) {
return;
}
// get first message in the list to use as before
const before = channel.messages.grouped[0][0].id;
logger.debug(`Fetching 50 messages before ${before} for channel ${channel.id}`);
channel.getMessages(app, false, 50, before).then((r) => {
if (r < 50) {
setHasMore(false);
}
});
};
return (
<Container id="scrollable-div">
<InfiniteScroll
dataLength={channel.messages.grouped.length}
next={fetchMore}
style={{ display: "flex", flexDirection: "column-reverse" }} // to put endMessage and loader to the top.
hasMore={hasMore}
loader={
<PulseLoader
style={{ display: "flex", justifyContent: "center", alignContent: "center" }}
color="var(--primary)"
/>
}
scrollableTarget="scrollable-div"
endMessage={
<EndMessageContainer>
<h1 style={{ fontWeight: 700, margin: "8px 0" }}>Welcome to #{channel.name}!</h1>
<p style={{ color: "var(--text-secondary)" }}>
This is the start of the #{channel.name} channel.
</p>
<HorizontalDivider />
</EndMessageContainer>
}
>
{channel.messages.grouped.map((group, index) => {
return <MessageGroup key={index} messages={group} />;
})}
</InfiniteScroll>
</Container>
);
}
export default observer(MessageList);

View File

@ -0,0 +1,64 @@
import { observer } from "mobx-react-lite";
import { useParams } from "react-router-dom";
import styled from "styled-components";
import useLogger from "../../hooks/useLogger";
import { useAppStore } from "../../stores/AppStore";
import MessageInput from "./MessageInput";
import MessageList from "./MessageList";
import MessagesHeader from "./MessagesHeader";
const Wrapper = styled.div`
display: flex;
flex-direction: column;
flex: 1 1 100%;
background-color: var(--background-primary-alt);
`;
const Container = styled.div`
display: flex;
flex-direction: column;
flex: 1 1 auto;
overflow: hidden;
position: relative;
`;
const Spacer = styled.div`
margin-bottom: 30px;
`;
/**
* Main component for rendering channel messages
*/
function Messages() {
const app = useAppStore();
const logger = useLogger("Messages");
const { guildId, channelId } = useParams<{
guildId: string;
channelId: string;
}>();
const guild = app.guilds.get(guildId!);
const channel = guild?.channels.get(channelId!);
if (!guild || !channel) {
return (
<Wrapper>
<MessagesHeader channel={channel} />
<span>{!guild ? "Unknown Guild" : "Unknown Channel"}</span>
</Wrapper>
);
}
return (
<Wrapper>
<MessagesHeader channel={channel} />
<Container>
<MessageList guild={guild} channel={channel} />
<Spacer />
<MessageInput channel={channel} />
</Container>
</Wrapper>
);
}
export default observer(Messages);

View File

@ -1,8 +1,8 @@
import * as Icons from "@mdi/js";
import styled from "styled-components";
import Channel from "../stores/objects/Channel";
import Icon from "./Icon";
import Tooltip from "./Tooltip";
import Channel from "../../stores/objects/Channel";
import Icon from "../Icon";
import Tooltip from "../Tooltip";
const IconButton = styled.button`
margin: 0;
@ -112,7 +112,10 @@ function ActionItem({ icon, active, ariaLabel, tooltip }: ActionItemProps) {
);
}
function ChatHeader({ channel }: Props) {
/**
* Top header for channel messages section
*/
function MessagesHeader({ channel }: Props) {
return (
<Container>
<Wrapper>
@ -137,4 +140,4 @@ function ChatHeader({ channel }: Props) {
);
}
export default ChatHeader;
export default MessagesHeader;

View File

@ -8,7 +8,7 @@ import useLogger from "../../hooks/useLogger";
import { useAppStore } from "../../stores/AppStore";
import { messageFromFieldError } from "../../utils/messageFromFieldError";
import { Input, InputErrorText, InputLabel, InputWrapper, LabelWrapper } from "../AuthComponents";
import { Divider } from "../Divider";
import { TextDivider } from "../Divider";
import Icon from "../Icon";
import AddServerModal from "./AddServerModal";
import {
@ -212,7 +212,7 @@ function CreateServerModal() {
{errors.name && (
<InputErrorText>
<>
<Divider>-</Divider>
<TextDivider>-</TextDivider>
{errors.name.message}
</>
</InputErrorText>

View File

@ -7,7 +7,7 @@ import useLogger from "../../hooks/useLogger";
import { useAppStore } from "../../stores/AppStore";
import { messageFromFieldError } from "../../utils/messageFromFieldError";
import { Input, InputErrorText, InputLabel, LabelWrapper } from "../AuthComponents";
import { Divider } from "../Divider";
import { TextDivider } from "../Divider";
import Icon from "../Icon";
import AddServerModal from "./AddServerModal";
import {
@ -130,7 +130,7 @@ function JoinServerModal() {
{errors.code && (
<InputErrorText>
<>
<Divider>-</Divider>
<TextDivider>-</TextDivider>
{errors.code.message}
</>
</InputErrorText>
@ -145,6 +145,7 @@ function JoinServerModal() {
error={!!errors.code}
disabled={isLoading}
autoFocus
minLength={6}
/>
</InviteInputContainer>
</form>

View File

@ -7,6 +7,8 @@ h6,
p,
span {
color: var(--text);
padding: 0;
margin: 0;
}
* {

View File

@ -22,7 +22,7 @@ import {
SubmitButton,
Wrapper,
} from "../components/AuthComponents";
import { Divider } from "../components/Divider";
import { TextDivider } from "../components/Divider";
import HCaptcha, { HeaderContainer } from "../components/HCaptcha";
import ForgotPasswordModal from "../components/modals/ForgotPasswordModal";
import useLogger from "../hooks/useLogger";
@ -244,7 +244,7 @@ function LoginPage() {
{isCheckingInstance != false && (
<InputErrorText>
<>
<Divider>-</Divider>
<TextDivider>-</TextDivider>
Checking
</>
</InputErrorText>
@ -252,7 +252,7 @@ function LoginPage() {
{errors.instance && (
<InputErrorText>
<>
<Divider>-</Divider>
<TextDivider>-</TextDivider>
{errors.instance.message}
</>
</InputErrorText>
@ -279,7 +279,7 @@ function LoginPage() {
{errors.login && (
<InputErrorText>
<>
<Divider>-</Divider>
<TextDivider>-</TextDivider>
{errors.login.message}
</>
</InputErrorText>
@ -303,7 +303,7 @@ function LoginPage() {
{errors.password && (
<InputErrorText>
<>
<Divider>-</Divider>
<TextDivider>-</TextDivider>
{errors.password.message}
</>
</InputErrorText>

View File

@ -22,7 +22,7 @@ import {
Wrapper,
} from "../components/AuthComponents";
import DOBInput from "../components/DOBInput";
import { Divider } from "../components/Divider";
import { TextDivider } from "../components/Divider";
import HCaptcha from "../components/HCaptcha";
import useLogger from "../hooks/useLogger";
import { AUTH_NO_BRANDING, useAppStore } from "../stores/AppStore";
@ -180,7 +180,7 @@ function RegistrationPage() {
{errors.email && (
<InputErrorText>
<>
<Divider>-</Divider>
<TextDivider>-</TextDivider>
{errors.email.message}
</>
</InputErrorText>
@ -204,7 +204,7 @@ function RegistrationPage() {
{errors.username && (
<InputErrorText>
<>
<Divider>-</Divider>
<TextDivider>-</TextDivider>
{errors.username.message}
</>
</InputErrorText>
@ -226,7 +226,7 @@ function RegistrationPage() {
{errors.password && (
<InputErrorText>
<>
<Divider>-</Divider>
<TextDivider>-</TextDivider>
{errors.password.message}
</>
</InputErrorText>
@ -249,7 +249,7 @@ function RegistrationPage() {
{errors.date_of_birth && (
<InputErrorText>
<>
<Divider>-</Divider>
<TextDivider>-</TextDivider>
{errors.date_of_birth.message}
</>
</InputErrorText>

View File

@ -1,11 +1,11 @@
import React from "react";
import styled from "styled-components";
import ChannelSidebar from "../../components/ChannelSidebar";
import Chat from "../../components/Chat";
import Container from "../../components/Container";
import ContextMenu from "../../components/ContextMenu";
import GuildSidebar from "../../components/GuildSidebar";
import MemberList from "../../components/MemberList";
import Messages from "../../components/messaging/Messages";
import { ContextMenuContext } from "../../contexts/ContextMenuContext";
const Wrapper = styled(Container)`
@ -17,7 +17,7 @@ function Test() {
return (
<>
<ChannelSidebar />
<Chat />
<Messages />
<MemberList />
</>
);

View File

@ -19,7 +19,7 @@ import {
SubmitButton,
Wrapper,
} from "../../components/AuthComponents";
import { Divider } from "../../components/Divider";
import { TextDivider } from "../../components/Divider";
import useLogger from "../../hooks/useLogger";
import { useAppStore } from "../../stores/AppStore";
import {
@ -108,7 +108,7 @@ function MFA(props: IAPILoginResponseMFARequired) {
{errors.code && (
<InputErrorText>
<>
<Divider>-</Divider>
<TextDivider>-</TextDivider>
{errors.code.message}
</>
</InputErrorText>

View File

@ -1,16 +1,20 @@
import type { APIMessage } from "@spacebarchat/spacebar-api-types/v9";
import type { IObservableArray } from "mobx";
import { action, computed, makeObservable, observable } from "mobx";
import useLogger from "../hooks/useLogger";
import Logger from "../utils/Logger";
import AppStore from "./AppStore";
import Message from "./objects/Message";
export default class MessageStore {
private readonly app: AppStore;
private readonly logger: Logger;
@observable private readonly messagesArr: IObservableArray<Message>;
constructor(app: AppStore) {
this.app = app;
this.logger = useLogger("MessageStore.ts");
this.messagesArr = observable.array([]);
@ -32,11 +36,32 @@ export default class MessageStore {
}
@computed
get messages() {
return this.messagesArr
.slice()
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime())
.filter((x) => x);
get grouped() {
const groupedMessages: Message[][] = [];
let lastAuthorId: string | undefined = undefined;
let lastTimestamp: Date | undefined = undefined;
let lastGroup: Message[] | undefined = undefined;
for (const message of this.messagesArr) {
if (
lastAuthorId !== message.author.id ||
!lastTimestamp ||
message.timestamp.getTime() - lastTimestamp.getTime() > 1000 * 60 * 7
) {
// start a new group
lastAuthorId = message.author.id;
lastTimestamp = message.timestamp;
lastGroup = [];
groupedMessages.push(lastGroup);
}
if (!lastGroup) {
// this should never happen
this.logger.error("lastGroup is undefined");
continue;
}
lastGroup.push(message);
}
return groupedMessages;
}
has(id: string) {

View File

@ -144,58 +144,61 @@ export default class Channel {
}
@action
async getMessages(
getMessages(
app: AppStore,
isInitial: boolean,
limit?: number,
before?: SnowflakeType,
after?: SnowflakeType,
around?: SnowflakeType,
) {
if (isInitial && this.hasFetchedMessages) {
return;
}
): Promise<number> {
return new Promise((resolve, reject) => {
if (isInitial && this.hasFetchedMessages) {
return;
}
let opts: RESTGetAPIChannelMessagesQuery = {
limit: limit || 50,
};
let opts: RESTGetAPIChannelMessagesQuery = {
limit: limit || 50,
};
if (before) {
opts = { ...opts, before };
}
if (after) {
opts = { ...opts, after };
}
if (around) {
opts = { ...opts, around };
}
if (before) {
opts = { ...opts, before };
}
if (after) {
opts = { ...opts, after };
}
if (around) {
opts = { ...opts, around };
}
this.hasFetchedMessages = true;
this.logger.info(`Fetching messags for ${this.id}`);
app.rest
.get<RESTGetAPIChannelMessagesResult | APIError>(Routes.channelMessages(this.id), opts)
.then((res) => {
if ("code" in res) {
this.logger.error(res);
return;
}
this.messages.addAll(
res.filter((x) => !this.messages.has(x.id)).reverse(),
// .sort((a, b) => {
// const aTimestamp = new Date(a.timestamp as unknown as string);
// const bTimestamp = new Date(b.timestamp as unknown as string);
// return aTimestamp.getTime() - bTimestamp.getTime();
// })
);
})
.catch((err) => {
this.logger.error(err);
});
this.hasFetchedMessages = true;
this.logger.info(`Fetching messags for ${this.id}`);
app.rest
.get<RESTGetAPIChannelMessagesResult | APIError>(Routes.channelMessages(this.id), opts)
.then((res) => {
if ("code" in res) {
this.logger.error(res);
return;
}
this.messages.addAll(
res.filter((x) => !this.messages.has(x.id)),
// .sort((a, b) => {
// const aTimestamp = new Date(a.timestamp as unknown as string);
// const bTimestamp = new Date(b.timestamp as unknown as string);
// return aTimestamp.getTime() - bTimestamp.getTime();
// })
);
resolve(res.length);
})
.catch((err) => {
this.logger.error(err);
reject(err);
});
});
}
@action
async sendMessage(data: RESTPostAPIChannelMessageJSONBody) {
// TODO: handle errors, highlight message as failed
return this.app.rest.post<RESTPostAPIChannelMessageJSONBody, RESTPostAPIChannelMessageResult>(
Routes.channelMessages(this.id),
data,