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

new message list and WIP read states

This commit is contained in:
Puyodead1 2024-07-11 20:12:46 -04:00
parent e20967d0f2
commit a8c91ca7a5
No known key found for this signature in database
GPG Key ID: A4FA4FEC0DD353FC
15 changed files with 197 additions and 37 deletions

View File

@ -48,6 +48,7 @@
"react": "^18.2.0", "react": "^18.2.0",
"react-advanced-cropper": "^0.19.6", "react-advanced-cropper": "^0.19.6",
"react-colorful": "^5.6.1", "react-colorful": "^5.6.1",
"react-content-loader": "^7.0.2",
"react-device-detect": "^2.2.3", "react-device-detect": "^2.2.3",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-fps-stats": "^0.3.1", "react-fps-stats": "^0.3.1",

View File

@ -137,6 +137,9 @@ dependencies:
react-colorful: react-colorful:
specifier: ^5.6.1 specifier: ^5.6.1
version: 5.6.1(react-dom@18.2.0)(react@18.2.0) version: 5.6.1(react-dom@18.2.0)(react@18.2.0)
react-content-loader:
specifier: ^7.0.2
version: 7.0.2(react@18.2.0)
react-device-detect: react-device-detect:
specifier: ^2.2.3 specifier: ^2.2.3
version: 2.2.3(react-dom@18.2.0)(react@18.2.0) version: 2.2.3(react-dom@18.2.0)(react@18.2.0)
@ -12956,6 +12959,15 @@ packages:
react-dom: 18.2.0(react@18.2.0) react-dom: 18.2.0(react@18.2.0)
dev: false dev: false
/react-content-loader@7.0.2(react@18.2.0):
resolution: {integrity: sha512-773S98JTyC8VB2nu7LXUhpHx8tZMieGxMcx3qTe7IkohT6Br7d9AXnIXs/wQ6IhlUdKQcw6JLKk1QKigYCWDRA==}
engines: {node: '>=10'}
peerDependencies:
react: '>=16.0.0'
dependencies:
react: 18.2.0
dev: false
/react-dev-utils@12.0.1(eslint@8.57.0)(typescript@5.4.5)(webpack@5.90.3): /react-dev-utils@12.0.1(eslint@8.57.0)(typescript@5.4.5)(webpack@5.90.3):
resolution: {integrity: sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ==} resolution: {integrity: sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ==}
engines: {node: '>=14'} engines: {node: '>=14'}

View File

@ -1,3 +1,4 @@
import { observer } from "mobx-react-lite";
import React, { useContext, useEffect } from "react"; import React, { useContext, useEffect } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import styled from "styled-components"; import styled from "styled-components";
@ -7,6 +8,7 @@ import { useAppStore } from "../../stores/AppStore";
import Channel from "../../stores/objects/Channel"; import Channel from "../../stores/objects/Channel";
import { Permissions } from "../../utils/Permissions"; import { Permissions } from "../../utils/Permissions";
import Icon from "../Icon"; import Icon from "../Icon";
import SidebarPill from "../SidebarPill";
import Floating from "../floating/Floating"; import Floating from "../floating/Floating";
import FloatingTrigger from "../floating/FloatingTrigger"; import FloatingTrigger from "../floating/FloatingTrigger";
@ -88,6 +90,7 @@ function ChannelListItem({ channel, isCategory, active }: Props) {
alignItems: "center", alignItems: "center",
}} }}
> >
<SidebarPill type={channel.hasUnread() ? "unread" : "none"} />
{channel.channelIcon && !isCategory && ( {channel.channelIcon && !isCategory && (
<Icon <Icon
icon={channel.channelIcon} icon={channel.channelIcon}
@ -163,4 +166,4 @@ function ChannelListItem({ channel, isCategory, active }: Props) {
); );
} }
export default ChannelListItem; export default observer(ChannelListItem);

View File

@ -5,7 +5,7 @@ export type PillType = "none" | "unread" | "hover" | "active";
const Wrapper = styled(Container)` const Wrapper = styled(Container)`
position: absolute; position: absolute;
top: 0; // top: 0;
left: 0; left: 0;
width: 8px; width: 8px;
height: 48px; height: 48px;

View File

@ -0,0 +1,27 @@
function SkeletonLoader() {
return (
<svg
role="img"
width="560"
height="66"
aria-labelledby="loading-aria"
viewBox="0 0 560 66"
preserveAspectRatio="none"
fill="#121212"
>
<title id="loading-aria">Loading...</title>
<rect x="0" y="0" width="100%" height="100%" clip-path="url(#clip-path)"></rect>
<defs>
<clipPath id="clip-path">
<circle cx="32" cy="18" r="16" />
<rect x="60" y="10" rx="4" ry="4" width="90" height="7" />
<rect x="160" y="10" rx="4" ry="4" width="110" height="7" />
<rect x="60" y="31" rx="3" ry="3" width="400" height="6" />
<rect x="60" y="42" rx="3" ry="3" width="270" height="6" />
</clipPath>
</defs>
</svg>
);
}
export default SkeletonLoader;

View File

@ -54,9 +54,12 @@ interface Props2 {
} }
function ChatContent({ channel, guild }: Props2) { function ChatContent({ channel, guild }: Props2) {
const app = useAppStore();
const readstate = app.readStateStore.get(channel.id);
return ( return (
<Container> <Container>
<MessageList guild={guild} channel={channel} /> <MessageList guild={guild} channel={channel} before={readstate?.lastMessageId} />
<MessageInput channel={channel} guild={guild} /> <MessageInput channel={channel} guild={guild} />
<TypingIndicator channel={channel} /> <TypingIndicator channel={channel} />
</Container> </Container>

View File

@ -70,7 +70,7 @@ function EmbedMedia({ embed, width, height, thumbnail }: Props) {
height = newHeight; height = newHeight;
} }
console.log(`Original size: ${originalWidth}x${originalHeight} - Scaled size: ${width}x${height}`); // console.log(`Original size: ${originalWidth}x${originalHeight} - Scaled size: ${width}x${height}`);
switch (embed.provider?.name) { switch (embed.provider?.name) {
case "YouTube": { case "YouTube": {

View File

@ -18,7 +18,7 @@ function MessageGroup({ group }: Props) {
<> <>
{messages.map((message, index) => { {messages.map((message, index) => {
if (message.type === MessageType.Default || message.type === MessageType.Reply) { if (message.type === MessageType.Default || message.type === MessageType.Reply) {
return <Message key={message.id} message={message} header={index === messages.length - 1} />; return <Message key={message.id} message={message} header={index === 0} />;
} else return <SystemMessage key={message.id} message={message} />; } else return <SystemMessage key={message.id} message={message} />;
})} })}
</> </>

View File

@ -1,6 +1,5 @@
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
import { PulseLoader } from "react-spinners";
import styled from "styled-components"; import styled from "styled-components";
import useResizeObserver from "use-resize-observer"; import useResizeObserver from "use-resize-observer";
import { VList, VListHandle } from "virtua"; import { VList, VListHandle } from "virtua";
@ -11,6 +10,7 @@ import Channel from "../../stores/objects/Channel";
import Guild from "../../stores/objects/Guild"; import Guild from "../../stores/objects/Guild";
import { Permissions } from "../../utils/Permissions"; import { Permissions } from "../../utils/Permissions";
import { HorizontalDivider } from "../Divider"; import { HorizontalDivider } from "../Divider";
import SkeletonLoader from "../SkeletonLoader.tsx";
import MessageGroup from "./MessageGroup"; import MessageGroup from "./MessageGroup";
export const MessageAreaWidthContext = React.createContext(0); export const MessageAreaWidthContext = React.createContext(0);
@ -43,18 +43,35 @@ function MessageList({ guild, channel, before }: Props) {
const logger = useLogger("MessageList.tsx"); const logger = useLogger("MessageList.tsx");
const [hasMore, setHasMore] = useState(true); const [hasMore, setHasMore] = useState(true);
const [canView, setCanView] = useState(false); const [canView, setCanView] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(true);
const messageGroups = channel.messages.groups; const messageGroups = channel.messages.groups;
const wrapperRef = useRef<HTMLDivElement>(null); const wrapperRef = useRef<HTMLDivElement>(null);
const { width } = useResizeObserver<HTMLDivElement>({ ref: wrapperRef }); const { width } = useResizeObserver<HTMLDivElement>({ ref: wrapperRef });
const ref = useRef<VListHandle>(null); const ref = useRef<VListHandle>(null);
useEffect(() => { useEffect(() => {
ref.current?.scrollToIndex( // if (before) {
messageGroups.reduce((p, c) => p + c.messages.length, 0) + // // find message group containing the message
1 /* +1 to account for the spacer that adds some margin to the bottom */, // const group = messageGroups.find((x) => x.messages.some((y) => y.id === before));
); // const index = group
}, [messageGroups]); // ? messageGroups.indexOf(group) +
// (hasMore
// ? 10
// : 0) /** +10 to account for the "skeleton" divs used to add some padding for scrolling */
// : -1;
// if (index !== -1) {
// ref.current?.scrollToIndex(index);
// return;
// }
// }
// this is for switching to a channel with cached messages, it ensures that we correctly set hasMore to false if there are messages but its less than 50
if (channel.messages.count > 0 && channel.messages.count < 50) setHasMore(false);
const hasSkeleton = hasMore || loading;
ref.current?.scrollToIndex(channel.messages.count + (hasSkeleton ? 30 : 0));
}, [messageGroups, hasMore, loading]);
const fetchMore = React.useCallback(() => { const fetchMore = React.useCallback(() => {
setLoading(true); setLoading(true);
@ -76,11 +93,9 @@ function MessageList({ guild, channel, before }: Props) {
const before = lastGroup.messages[0].id; const before = lastGroup.messages[0].id;
logger.debug(`Fetching 50 messages before ${before} for channel ${channel.id}`); logger.debug(`Fetching 50 messages before ${before} for channel ${channel.id}`);
channel.getMessages(app, false, 50, before).then((r) => { channel.getMessages(app, false, 50, before).then((r) => {
if (r < 50) { if (r < 50) setHasMore(false);
setHasMore(false);
}
});
setLoading(false); setLoading(false);
});
}, [channel, messageGroups]); }, [channel, messageGroups]);
useEffect(() => { useEffect(() => {
@ -94,15 +109,19 @@ function MessageList({ guild, channel, before }: Props) {
} }
if (guild && channel && channel.messages.count === 0) { if (guild && channel && channel.messages.count === 0) {
channel.getMessages(app, true, 50, before).then((r) => { logger.debug(`Fetching 50 messages for channel ${channel.id}`);
if (r < 50) { channel.getMessages(app, true).then((r) => {
setHasMore(false); if (r < 50) setHasMore(false);
}
setLoading(false);
}); });
} else if (channel.messages.count !== 0) {
setLoading(false);
} }
return () => { return () => {
logger.debug("MessageList unmounted"); logger.debug("MessageList unmounted");
setLoading(true);
setHasMore(true); setHasMore(true);
setCanView(false); setCanView(false);
}; };
@ -126,7 +145,12 @@ function MessageList({ guild, channel, before }: Props) {
}} }}
reverse reverse
> >
{((messageGroups.length === 0 && !loading) || (!loading && !hasMore)) && ( {Array.from({
length: hasMore || loading ? 30 : 0,
}).map((_, i) => (
<SkeletonLoader />
))}
{!loading && !hasMore && (
<EndMessageContainer> <EndMessageContainer>
<h1 style={{ fontWeight: 700, margin: "8px 0" }}>Welcome to #{channel.name}!</h1> <h1 style={{ fontWeight: 700, margin: "8px 0" }}>Welcome to #{channel.name}!</h1>
<p style={{ color: "var(--text-secondary)" }}> <p style={{ color: "var(--text-secondary)" }}>
@ -135,17 +159,6 @@ function MessageList({ guild, channel, before }: Props) {
<HorizontalDivider /> <HorizontalDivider />
</EndMessageContainer> </EndMessageContainer>
)} )}
{loading && (
<PulseLoader
style={{
display: "flex",
justifyContent: "center",
alignContent: "center",
margin: 30,
}}
color="var(--primary)"
/>
)}
{messageGroups.map((group, index) => renderGroup(group))} {messageGroups.map((group, index) => renderGroup(group))}
<Spacer /> <Spacer />
</VList> </VList>

View File

@ -13,6 +13,7 @@ import GuildStore from "./GuildStore";
import MessageQueue from "./MessageQueue"; import MessageQueue from "./MessageQueue";
import PresenceStore from "./PresenceStore"; import PresenceStore from "./PresenceStore";
import PrivateChannelStore from "./PrivateChannelStore"; import PrivateChannelStore from "./PrivateChannelStore";
import ReadStateStore from "./ReadStateStore";
import RoleStore from "./RoleStore"; import RoleStore from "./RoleStore";
import ThemeStore from "./ThemeStore"; import ThemeStore from "./ThemeStore";
import UpdaterStore from "./UpdaterStore"; import UpdaterStore from "./UpdaterStore";
@ -47,6 +48,7 @@ export default class AppStore {
@observable rest = new REST(this); @observable rest = new REST(this);
@observable experiments = new ExperimentsStore(); @observable experiments = new ExperimentsStore();
@observable presences = new PresenceStore(this); @observable presences = new PresenceStore(this);
@observable readStateStore = new ReadStateStore(this);
@observable queue = new MessageQueue(this); @observable queue = new MessageQueue(this);
@observable updaterStore: UpdaterStore | null = null; @observable updaterStore: UpdaterStore | null = null;

View File

@ -469,7 +469,7 @@ export default class GatewayConnectionStore {
*/ */
private onReady = (data: GatewayReadyDispatchData) => { private onReady = (data: GatewayReadyDispatchData) => {
this.logger.info(`[Ready] took ${Date.now() - this.connectionStartTime!}ms`); this.logger.info(`[Ready] took ${Date.now() - this.connectionStartTime!}ms`);
const { session_id, guilds, users, user, private_channels, sessions } = data; const { session_id, guilds, users, user, private_channels, sessions, read_state } = data;
this.sessionId = session_id; this.sessionId = session_id;
this.session = (sessions as GatewaySession[]).find((x) => x.session_id === session_id); this.session = (sessions as GatewaySession[]).find((x) => x.session_id === session_id);
@ -480,8 +480,9 @@ export default class GatewayConnectionStore {
if (users) { if (users) {
this.app.users.addAll(users); this.app.users.addAll(users);
} }
// TODO: store relationships // TODO: store relationships
// TODO: store readstates this.app.readStateStore.addAll(read_state.entries);
this.app.privateChannels.addAll(private_channels); this.app.privateChannels.addAll(private_channels);
if (data.merged_members) { if (data.merged_members) {

View File

@ -86,7 +86,7 @@ export default class MessageStore {
const sortedGroups = sortedMessages const sortedGroups = sortedMessages
.slice() .slice()
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime()) .sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
.reduce((groups, message) => { .reduce((groups, message) => {
const lastGroup = groups[groups.length - 1]; const lastGroup = groups[groups.length - 1];
const lastMessage = lastGroup?.messages[lastGroup.messages.length - 1]; const lastMessage = lastGroup?.messages[lastGroup.messages.length - 1];
@ -95,7 +95,7 @@ export default class MessageStore {
lastMessage.author.id === message.author.id && lastMessage.author.id === message.author.id &&
lastMessage.type === message.type && lastMessage.type === message.type &&
message.type === MessageType.Default && message.type === MessageType.Default &&
message.timestamp.getTime() - lastMessage.timestamp.getTime() <= 10 * 60 * 1000 lastMessage.timestamp.getTime() - message.timestamp.getTime() <= 10 * 60 * 1000
) { ) {
// add to last group // add to last group
lastGroup.messages.unshift(message); lastGroup.messages.unshift(message);
@ -107,7 +107,8 @@ export default class MessageStore {
}); });
} }
return groups; return groups;
}, [] as MessageGroup[]); }, [] as MessageGroup[])
.reverse();
return sortedGroups; return sortedGroups;
} }

View File

@ -0,0 +1,61 @@
import type { APIReadState } from "@spacebarchat/spacebar-api-types/v9";
import { ObservableMap, action, computed, observable } from "mobx";
import AppStore from "./AppStore";
import ReadState from "./objects/ReadState";
export default class ReadStateStore {
private readonly app: AppStore;
@observable readonly readstates = new ObservableMap<string, ReadState>();
constructor(app: AppStore) {
this.app = app;
}
@action
add(readstate: APIReadState) {
this.readstates.set(readstate.id, new ReadState(this.app, readstate));
}
@action
update(readstate: APIReadState) {
const existing = this.readstates.get(readstate.id);
if (existing) {
existing.update(readstate);
} else {
this.add(readstate);
}
}
@action
addAll(readstates: APIReadState[]) {
readstates.forEach((readstate) => this.add(readstate));
}
/**
* Get a channels readstate
* @param id channel id
* @returns
*/
get(id: string) {
return this.readstates.get(id);
}
@computed
get all() {
return Array.from(this.readstates.values());
}
@action
remove(id: string) {
this.readstates.delete(id);
}
@computed
get count() {
return this.readstates.size;
}
has(id: string) {
return this.readstates.has(id);
}
}

View File

@ -310,4 +310,14 @@ export default class Channel {
return listId; return listId;
} }
hasUnread() {
const readState = this.app.readStateStore.get(this.id);
if (!readState) {
// this.logger.warn(`Failed to find readstate for channel ${this.id}`); // this just causes unnecessary spam
return false;
}
return readState.lastMessageId !== this.lastMessageId;
}
} }

View File

@ -0,0 +1,26 @@
import type { APIReadState } from "@spacebarchat/spacebar-api-types/v9";
import { action, observable } from "mobx";
import AppStore from "../AppStore";
export default class ReadState {
private readonly app: AppStore;
id: string;
@observable lastMessageId: string;
@observable lastPinTimestamp: string | null;
@observable mentionCount: number | null;
constructor(app: AppStore, data: APIReadState) {
this.app = app;
this.id = data.id;
this.lastMessageId = data.last_message_id;
this.lastPinTimestamp = data.last_pin_timestamp;
this.mentionCount = data.mention_count;
}
@action
update(role: APIReadState) {
Object.assign(this, role);
}
}