diff --git a/package.json b/package.json index 6b9c53e..58e1785 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3baac56..6c63368 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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'} diff --git a/src/components/ChannelList/ChannelListItem.tsx b/src/components/ChannelList/ChannelListItem.tsx index 786d1fd..014cb62 100644 --- a/src/components/ChannelList/ChannelListItem.tsx +++ b/src/components/ChannelList/ChannelListItem.tsx @@ -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", }} > + {channel.channelIcon && !isCategory && ( + Loading... + + + + + + + + + + + + ); +} + +export default SkeletonLoader; diff --git a/src/components/messaging/Chat.tsx b/src/components/messaging/Chat.tsx index fa2e9b2..3d42bc2 100644 --- a/src/components/messaging/Chat.tsx +++ b/src/components/messaging/Chat.tsx @@ -54,9 +54,12 @@ interface Props2 { } function ChatContent({ channel, guild }: Props2) { + const app = useAppStore(); + const readstate = app.readStateStore.get(channel.id); + return ( - + diff --git a/src/components/messaging/EmbedMedia.tsx b/src/components/messaging/EmbedMedia.tsx index f126736..aae58a2 100644 --- a/src/components/messaging/EmbedMedia.tsx +++ b/src/components/messaging/EmbedMedia.tsx @@ -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": { diff --git a/src/components/messaging/MessageGroup.tsx b/src/components/messaging/MessageGroup.tsx index 5f738b3..a3efe73 100644 --- a/src/components/messaging/MessageGroup.tsx +++ b/src/components/messaging/MessageGroup.tsx @@ -18,7 +18,7 @@ function MessageGroup({ group }: Props) { <> {messages.map((message, index) => { if (message.type === MessageType.Default || message.type === MessageType.Reply) { - return ; + return ; } else return ; })} diff --git a/src/components/messaging/MessageList.tsx b/src/components/messaging/MessageList.tsx index f7d8961..a1a944c 100644 --- a/src/components/messaging/MessageList.tsx +++ b/src/components/messaging/MessageList.tsx @@ -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(null); const { width } = useResizeObserver({ ref: wrapperRef }); const ref = useRef(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) => ( + + ))} + {!loading && !hasMore && (

Welcome to #{channel.name}!

@@ -135,17 +159,6 @@ function MessageList({ guild, channel, before }: Props) { )} - {loading && ( - - )} {messageGroups.map((group, index) => renderGroup(group))} diff --git a/src/stores/AppStore.ts b/src/stores/AppStore.ts index d546bd2..7ebecbe 100644 --- a/src/stores/AppStore.ts +++ b/src/stores/AppStore.ts @@ -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; diff --git a/src/stores/GatewayConnectionStore.ts b/src/stores/GatewayConnectionStore.ts index f0cf05c..2092ed0 100644 --- a/src/stores/GatewayConnectionStore.ts +++ b/src/stores/GatewayConnectionStore.ts @@ -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) { diff --git a/src/stores/MessageStore.ts b/src/stores/MessageStore.ts index 5a0b551..f79eefe 100644 --- a/src/stores/MessageStore.ts +++ b/src/stores/MessageStore.ts @@ -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; } diff --git a/src/stores/ReadStateStore.ts b/src/stores/ReadStateStore.ts new file mode 100644 index 0000000..811463e --- /dev/null +++ b/src/stores/ReadStateStore.ts @@ -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(); + + 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); + } +} diff --git a/src/stores/objects/Channel.ts b/src/stores/objects/Channel.ts index a2a9d81..5a767a8 100644 --- a/src/stores/objects/Channel.ts +++ b/src/stores/objects/Channel.ts @@ -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; + } } diff --git a/src/stores/objects/ReadState.ts b/src/stores/objects/ReadState.ts new file mode 100644 index 0000000..a4b255d --- /dev/null +++ b/src/stores/objects/ReadState.ts @@ -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); + } +}