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:
parent
e20967d0f2
commit
a8c91ca7a5
@ -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",
|
||||
|
@ -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'}
|
||||
|
@ -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);
|
||||
|
@ -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;
|
||||
|
27
src/components/SkeletonLoader.tsx
Normal file
27
src/components/SkeletonLoader.tsx
Normal 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;
|
@ -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>
|
||||
|
@ -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": {
|
||||
|
@ -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} />;
|
||||
})}
|
||||
</>
|
||||
|
@ -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>
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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) {
|
||||
|
@ -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;
|
||||
}
|
||||
|
61
src/stores/ReadStateStore.ts
Normal file
61
src/stores/ReadStateStore.ts
Normal 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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
26
src/stores/objects/ReadState.ts
Normal file
26
src/stores/objects/ReadState.ts
Normal 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);
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user