1
0
mirror of https://github.com/spacebarchat/client.git synced 2024-11-21 18:02:32 +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-advanced-cropper": "^0.19.6",
"react-colorful": "^5.6.1",
"react-content-loader": "^7.0.2",
"react-device-detect": "^2.2.3",
"react-dom": "^18.2.0",
"react-fps-stats": "^0.3.1",

View File

@ -137,6 +137,9 @@ dependencies:
react-colorful:
specifier: ^5.6.1
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:
specifier: ^2.2.3
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)
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):
resolution: {integrity: sha512-84Ivxmr17KjUupyqzFode6xKhjwuEJDROWKJy/BthkL7Wn6NJ8h4WE6k/exAv6ImS+0oZLRRW5j/aINMHyeGeQ==}
engines: {node: '>=14'}

View File

@ -1,3 +1,4 @@
import { observer } from "mobx-react-lite";
import React, { useContext, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import styled from "styled-components";
@ -7,6 +8,7 @@ import { useAppStore } from "../../stores/AppStore";
import Channel from "../../stores/objects/Channel";
import { Permissions } from "../../utils/Permissions";
import Icon from "../Icon";
import SidebarPill from "../SidebarPill";
import Floating from "../floating/Floating";
import FloatingTrigger from "../floating/FloatingTrigger";
@ -88,6 +90,7 @@ function ChannelListItem({ channel, isCategory, active }: Props) {
alignItems: "center",
}}
>
<SidebarPill type={channel.hasUnread() ? "unread" : "none"} />
{channel.channelIcon && !isCategory && (
<Icon
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)`
position: absolute;
top: 0;
// top: 0;
left: 0;
width: 8px;
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) {
const app = useAppStore();
const readstate = app.readStateStore.get(channel.id);
return (
<Container>
<MessageList guild={guild} channel={channel} />
<MessageList guild={guild} channel={channel} before={readstate?.lastMessageId} />
<MessageInput channel={channel} guild={guild} />
<TypingIndicator channel={channel} />
</Container>

View File

@ -70,7 +70,7 @@ function EmbedMedia({ embed, width, height, thumbnail }: Props) {
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) {
case "YouTube": {

View File

@ -18,7 +18,7 @@ function MessageGroup({ group }: Props) {
<>
{messages.map((message, index) => {
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} />;
})}
</>

View File

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

View File

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

View File

@ -469,7 +469,7 @@ export default class GatewayConnectionStore {
*/
private onReady = (data: GatewayReadyDispatchData) => {
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.session = (sessions as GatewaySession[]).find((x) => x.session_id === session_id);
@ -480,8 +480,9 @@ export default class GatewayConnectionStore {
if (users) {
this.app.users.addAll(users);
}
// TODO: store relationships
// TODO: store readstates
this.app.readStateStore.addAll(read_state.entries);
this.app.privateChannels.addAll(private_channels);
if (data.merged_members) {

View File

@ -86,7 +86,7 @@ export default class MessageStore {
const sortedGroups = sortedMessages
.slice()
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime())
.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime())
.reduce((groups, message) => {
const lastGroup = groups[groups.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.type === message.type &&
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
lastGroup.messages.unshift(message);
@ -107,7 +107,8 @@ export default class MessageStore {
});
}
return groups;
}, [] as MessageGroup[]);
}, [] as MessageGroup[])
.reverse();
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;
}
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);
}
}