diff --git a/packages/app/package.json b/packages/app/package.json index 249d2931..0f15904c 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -12,11 +12,12 @@ "dependencies": { "@quack/config": "workspace:*", "@quack/rpc": "workspace:*", + "@reduxjs/toolkit": "2.2.3", "fuse.js": "7.0.0", "prop-types": "15.8.1", "react": "18.2.0", "react-dom": "18.2.0", - "react-redux": "9.0.4", + "react-redux": "9.1.0", "redux": "5.0.0", "socket.io-client": "4.7.2", "styled-components": "6.1.1", diff --git a/packages/app/src/js/components/Secured.tsx b/packages/app/src/js/components/Secured.tsx index aee05206..7e838c19 100644 --- a/packages/app/src/js/components/Secured.tsx +++ b/packages/app/src/js/components/Secured.tsx @@ -1,9 +1,8 @@ import { useEffect } from 'react'; -import { Provider } from 'react-redux'; import { ThemeProvider } from 'styled-components'; import '../setup'; import { client } from '../core'; -import { store } from '../store'; +import StoreProvider from '../store/components/provider'; import { Workspace } from './pages/Workspace'; import { useUser } from './contexts/useUser'; @@ -45,11 +44,11 @@ const Secured = () => { }, [user]); return ( - + - + ); }; diff --git a/packages/app/src/js/components/atoms/StatusLine.tsx b/packages/app/src/js/components/atoms/StatusLine.tsx index d879ad43..971d4806 100644 --- a/packages/app/src/js/components/atoms/StatusLine.tsx +++ b/packages/app/src/js/components/atoms/StatusLine.tsx @@ -1,8 +1,8 @@ -import { useSelector } from 'react-redux'; -import { useTyping } from '../../hooks'; +import { useTyping} from '../../hooks'; +import { useSelector } from '../../store'; export const StatusLine = () => { - const info = useSelector((state: any) => state.info); + const info = useSelector((state) => state.info); const typing = useTyping(); // FIXME: status line should work in context const names = (typing || []).map((u) => u.name).join(', '); diff --git a/packages/app/src/js/components/atoms/UserCircle.tsx b/packages/app/src/js/components/atoms/UserCircle.tsx index f8b9edc8..8be6f74c 100644 --- a/packages/app/src/js/components/atoms/UserCircle.tsx +++ b/packages/app/src/js/components/atoms/UserCircle.tsx @@ -1,6 +1,6 @@ -import { useSelector } from 'react-redux'; import styled from 'styled-components'; import { cn, ClassNames } from '../../utils'; +import { useSelector } from '../../store'; const Image = styled.img` display: inline-block; @@ -17,7 +17,7 @@ type UserCircleProps = { export const UserCircle = ({ userId, className }: UserCircleProps) => { // FIXME: state type - const user = useSelector((state: any) => state.users[userId]); + const user = useSelector((state) => state.users[userId]); if (!user) return null; return ( {user.name} diff --git a/packages/app/src/js/components/contexts/input.tsx b/packages/app/src/js/components/contexts/input.tsx index 56c4bbc6..e8f17d93 100644 --- a/packages/app/src/js/components/contexts/input.tsx +++ b/packages/app/src/js/components/contexts/input.tsx @@ -1,7 +1,7 @@ import React, { useRef, useState, useCallback, useEffect, createContext, MutableRefObject, } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch, useSelector } from '../../store'; import { useStream } from './useStream'; import * as messageService from '../../services/messages'; import { uploadMany } from '../../services/file'; @@ -52,14 +52,14 @@ type InputContextProps = { export const InputProvider = (args: InputContextProps) => { const { children, mode = 'default', messageId = null } = args; - const dispatch: any = useDispatch(); + const dispatch = useDispatch(); const [stream] = useStream(); const [currentText, setCurrentText] = useState(''); const [scope, setScope] = useState(''); const [scopeContainer, setScopeContainer] = useState(); //FIXME: files as any - const files = useSelector((state: any) => state.files); - const filesAreReady = !files || files.every((f: any) => f.status === 'ok'); + const files = useSelector((state) => state.files); + const filesAreReady = !files || files.every((f) => f.status === 'ok'); const message = useMessage(messageId); const input = useRef(null); diff --git a/packages/app/src/js/components/contexts/useMessages.ts b/packages/app/src/js/components/contexts/useMessages.ts index 1f551cd6..5cf3973a 100644 --- a/packages/app/src/js/components/contexts/useMessages.ts +++ b/packages/app/src/js/components/contexts/useMessages.ts @@ -1,7 +1,7 @@ import { useMemo, useEffect, useState } from 'react'; -import { useSelector, useDispatch } from 'react-redux'; +import { useSelector, useDispatch } from '../../store'; import { loadMessages, loadNext, loadPrevious } from '../../services/messages'; import { Message } from '../../types'; import { useStream } from './useStream'; diff --git a/packages/app/src/js/components/molecules/Attachments.tsx b/packages/app/src/js/components/molecules/Attachments.tsx index 3c64d1f6..2302532d 100644 --- a/packages/app/src/js/components/molecules/Attachments.tsx +++ b/packages/app/src/js/components/molecules/Attachments.tsx @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch, useSelector } from '../../store'; import styled from 'styled-components'; import { abort } from '../../services/file'; import { useStream } from '../contexts/useStream'; diff --git a/packages/app/src/js/components/molecules/ChannelCreate.tsx b/packages/app/src/js/components/molecules/ChannelCreate.tsx index 825438c8..82226b01 100644 --- a/packages/app/src/js/components/molecules/ChannelCreate.tsx +++ b/packages/app/src/js/components/molecules/ChannelCreate.tsx @@ -1,5 +1,5 @@ import { useState, useCallback } from 'react'; -import { useDispatch } from 'react-redux'; +import { useDispatch } from '../../store'; import styled from 'styled-components'; const NewChannelContainer = styled.div` diff --git a/packages/app/src/js/components/molecules/ChannelLink.tsx b/packages/app/src/js/components/molecules/ChannelLink.tsx index 31b0a927..775dfe8f 100644 --- a/packages/app/src/js/components/molecules/ChannelLink.tsx +++ b/packages/app/src/js/components/molecules/ChannelLink.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch, useSelector } from '../../store'; import styled from 'styled-components'; import { Icon } from '../atoms/Icon'; diff --git a/packages/app/src/js/components/molecules/InputEmojiSelector.tsx b/packages/app/src/js/components/molecules/InputEmojiSelector.tsx index fc68f99b..29372fbc 100644 --- a/packages/app/src/js/components/molecules/InputEmojiSelector.tsx +++ b/packages/app/src/js/components/molecules/InputEmojiSelector.tsx @@ -1,4 +1,4 @@ -import { useSelector } from 'react-redux'; +import { useSelector } from '../../store'; import { useCallback, useEffect, useState, useMemo, } from 'react'; diff --git a/packages/app/src/js/components/molecules/LoadingIndicator.tsx b/packages/app/src/js/components/molecules/LoadingIndicator.tsx index bd141c33..6332d363 100644 --- a/packages/app/src/js/components/molecules/LoadingIndicator.tsx +++ b/packages/app/src/js/components/molecules/LoadingIndicator.tsx @@ -1,4 +1,4 @@ -import { useSelector } from 'react-redux'; +import { useSelector } from '../../store'; import { Loader } from '../atoms/Loader'; export function LoadingIndicator() { diff --git a/packages/app/src/js/components/molecules/MessageToolbar.tsx b/packages/app/src/js/components/molecules/MessageToolbar.tsx index 3378d0fd..dcc7887a 100644 --- a/packages/app/src/js/components/molecules/MessageToolbar.tsx +++ b/packages/app/src/js/components/molecules/MessageToolbar.tsx @@ -1,5 +1,5 @@ import { useState, useCallback, useEffect } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch, useSelector } from '../../store'; import { removeMessage } from '../../services/messages'; import { useHovered } from '../contexts/useHovered'; import { useStream } from '../contexts/useStream'; @@ -71,15 +71,15 @@ export const MessageToolbar = () => { dispatch.methods.messages.addReaction(id, emoji)} /> + onClick={() => dispatch.methods.messages.addReaction({id, text: emoji})} /> ); const deleteButton = () => setView('delete')} />; const confirmDelete = () => ; const cancelButton = () => setView(null)} />; const editButton = () => dispatch.actions.messages.toggleEdit(id)} />; const openReactions = () => setView('reactions')} />; - const pinButton = () => dispatch.methods.pins.pin(id, channelId)} />; - const unpinButton = () => dispatch.methods.pins.unpin(id, channelId)} />; + const pinButton = () => dispatch.methods.pins.pin({id, channelId})} />; + const unpinButton = () => dispatch.methods.pins.unpin({id, channelId})} />; const replyButton = () => dispatch.actions.stream.open({ id: 'side', value: { type: 'live', channelId, parentId: id } })} />; return ( diff --git a/packages/app/src/js/components/molecules/NavChannel.tsx b/packages/app/src/js/components/molecules/NavChannel.tsx index eba3d43f..0458475d 100644 --- a/packages/app/src/js/components/molecules/NavChannel.tsx +++ b/packages/app/src/js/components/molecules/NavChannel.tsx @@ -1,5 +1,5 @@ import { useEffect } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch, useSelector } from '../../store'; import styled from 'styled-components'; import { Badge } from '../atoms/Badge'; import { TextWithIcon } from './TextWithIcon'; diff --git a/packages/app/src/js/components/molecules/NavChannels.tsx b/packages/app/src/js/components/molecules/NavChannels.tsx index 64dc8d37..8a308bed 100644 --- a/packages/app/src/js/components/molecules/NavChannels.tsx +++ b/packages/app/src/js/components/molecules/NavChannels.tsx @@ -1,5 +1,5 @@ import {useState} from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch, useSelector } from '../../store'; import styled from 'styled-components'; import { ChannelCreate } from './ChannelCreate'; import { Channel } from './NavChannel'; diff --git a/packages/app/src/js/components/molecules/NavUser.tsx b/packages/app/src/js/components/molecules/NavUser.tsx index 8e078620..217265b6 100644 --- a/packages/app/src/js/components/molecules/NavUser.tsx +++ b/packages/app/src/js/components/molecules/NavUser.tsx @@ -1,4 +1,4 @@ -import { useSelector } from 'react-redux'; +import { useSelector } from '../../store'; import { NavButton } from './NavButton'; import { ClassNames, cn } from '../../utils'; diff --git a/packages/app/src/js/components/molecules/NavUsers.tsx b/packages/app/src/js/components/molecules/NavUsers.tsx index 7174d3dc..e8ef5e3f 100644 --- a/packages/app/src/js/components/molecules/NavUsers.tsx +++ b/packages/app/src/js/components/molecules/NavUsers.tsx @@ -1,4 +1,4 @@ -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch, useSelector } from '../../store'; import styled from 'styled-components'; import { NavUserButton } from './NavUser'; import { useBadges, useUserChannels } from '../../hooks'; diff --git a/packages/app/src/js/components/molecules/Reactions.tsx b/packages/app/src/js/components/molecules/Reactions.tsx index e5d6609d..3cb7ab86 100644 --- a/packages/app/src/js/components/molecules/Reactions.tsx +++ b/packages/app/src/js/components/molecules/Reactions.tsx @@ -1,4 +1,4 @@ -import { useDispatch } from 'react-redux'; +import { useDispatch } from '../../store'; import { useMessageData } from '../contexts/useMessageData'; import { Emoji } from './Emoji'; import { Tag } from '../atoms/Tag'; @@ -12,7 +12,7 @@ export const Reactions = () => { return (
{Object.entries(reactionMap).map(([key, count]) => ( - dispatch.methods.messages.addReaction(id, key)}> + dispatch.methods.messages.addReaction({id, text: key})}> {count > 1 ? `${count} ` : ''} diff --git a/packages/app/src/js/components/molecules/ThreadLink.tsx b/packages/app/src/js/components/molecules/ThreadLink.tsx index 7cd41f18..f6eb4acc 100644 --- a/packages/app/src/js/components/molecules/ThreadLink.tsx +++ b/packages/app/src/js/components/molecules/ThreadLink.tsx @@ -1,5 +1,5 @@ import styled from 'styled-components'; -import { useDispatch } from 'react-redux'; +import { useDispatch } from '../../store'; const Link = styled.span` color: ${(props) => props.theme.linkColor}; diff --git a/packages/app/src/js/components/molecules/UserMention.tsx b/packages/app/src/js/components/molecules/UserMention.tsx index 602a93c5..fbd03c6d 100644 --- a/packages/app/src/js/components/molecules/UserMention.tsx +++ b/packages/app/src/js/components/molecules/UserMention.tsx @@ -1,4 +1,4 @@ -import { useSelector, useDispatch } from 'react-redux'; +import { useSelector, useDispatch } from '../../store'; import styled from 'styled-components'; import { gotoDirectChannel } from '../../services/channels'; diff --git a/packages/app/src/js/components/organisms/Conversation.tsx b/packages/app/src/js/components/organisms/Conversation.tsx index 584bb6d7..7d4c24ea 100644 --- a/packages/app/src/js/components/organisms/Conversation.tsx +++ b/packages/app/src/js/components/organisms/Conversation.tsx @@ -1,5 +1,5 @@ import { useEffect, useCallback} from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch, useSelector } from '../../store'; import { MessageList } from './MessageListScroller'; import { uploadMany } from '../../services/file'; import { Input } from '../organisms/Input'; diff --git a/packages/app/src/js/components/organisms/EmojiSearch.tsx b/packages/app/src/js/components/organisms/EmojiSearch.tsx index 0e1f9df2..d4df4549 100644 --- a/packages/app/src/js/components/organisms/EmojiSearch.tsx +++ b/packages/app/src/js/components/organisms/EmojiSearch.tsx @@ -1,5 +1,5 @@ import { useState, useEffect} from 'react'; -import { useSelector } from 'react-redux'; +import { useSelector } from '../../store'; import { Tooltip } from '../atoms/Tooltip'; import { SearchBox } from '../atoms/SearchBox'; import { useEmojiFuse } from '../../hooks'; diff --git a/packages/app/src/js/components/organisms/Input.tsx b/packages/app/src/js/components/organisms/Input.tsx index 6300863b..ba5f3289 100644 --- a/packages/app/src/js/components/organisms/Input.tsx +++ b/packages/app/src/js/components/organisms/Input.tsx @@ -1,5 +1,5 @@ import { useCallback, useState, useEffect } from 'react'; -import { useDispatch } from 'react-redux'; +import { useDispatch } from '../../store'; import styled from 'styled-components'; import { EmojiDescriptor } from '../../types'; diff --git a/packages/app/src/js/components/organisms/MainConversaion.tsx b/packages/app/src/js/components/organisms/MainConversaion.tsx index cb7fe6a7..6f607336 100644 --- a/packages/app/src/js/components/organisms/MainConversaion.tsx +++ b/packages/app/src/js/components/organisms/MainConversaion.tsx @@ -1,6 +1,6 @@ import styled from 'styled-components'; import { Conversation } from './Conversation'; -import { useDispatch } from 'react-redux'; +import { useDispatch } from '../../store'; import { Channel } from '../molecules/NavChannel'; import { init } from '../../services/init'; import { useStream } from '../contexts/useStream'; diff --git a/packages/app/src/js/components/organisms/Message.tsx b/packages/app/src/js/components/organisms/Message.tsx index 4815f389..af053312 100644 --- a/packages/app/src/js/components/organisms/Message.tsx +++ b/packages/app/src/js/components/organisms/Message.tsx @@ -1,5 +1,5 @@ import { useCallback } from 'react'; -import { useDispatch } from 'react-redux'; +import { useDispatch } from '../../store'; import styled from 'styled-components'; import { resend } from '../../services/messages'; diff --git a/packages/app/src/js/components/organisms/SideConversation.tsx b/packages/app/src/js/components/organisms/SideConversation.tsx index fb09a4bc..a5baa38f 100644 --- a/packages/app/src/js/components/organisms/SideConversation.tsx +++ b/packages/app/src/js/components/organisms/SideConversation.tsx @@ -1,6 +1,6 @@ import styled from 'styled-components'; import { Conversation } from './Conversation'; -import { useDispatch } from 'react-redux'; +import { useDispatch } from '../../store'; import { Channel } from '../molecules/NavChannel'; import { useStream } from '../contexts/useStream'; import { useMessage } from '../../hooks'; diff --git a/packages/app/src/js/components/organisms/Sidebar.tsx b/packages/app/src/js/components/organisms/Sidebar.tsx index 6beebdf7..a31609e8 100644 --- a/packages/app/src/js/components/organisms/Sidebar.tsx +++ b/packages/app/src/js/components/organisms/Sidebar.tsx @@ -5,7 +5,7 @@ import { NavUsers } from '../molecules/NavUsers'; import { NavButton } from '../molecules/NavButton'; import plugins from '../../core/plugins'; import { logout } from '../../services/session'; -import { useDispatch } from 'react-redux'; +import { useDispatch } from '../../store'; export const SideMenu = styled.div` diff --git a/packages/app/src/js/components/pages/Pins.tsx b/packages/app/src/js/components/pages/Pins.tsx index 03f1ef81..eaca89c2 100644 --- a/packages/app/src/js/components/pages/Pins.tsx +++ b/packages/app/src/js/components/pages/Pins.tsx @@ -2,7 +2,7 @@ import styled from 'styled-components'; import { Channel } from '../molecules/NavChannel'; import { useStream } from '../contexts/useStream'; import { useCallback } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch, useSelector } from '../../store'; import { HoverProvider } from '../contexts/hover'; import { MessageList } from '../organisms/MessageListScroller' import { Message as MessageType } from '../../types'; diff --git a/packages/app/src/js/components/pages/Search.tsx b/packages/app/src/js/components/pages/Search.tsx index d3ef8d9b..25c1abe8 100644 --- a/packages/app/src/js/components/pages/Search.tsx +++ b/packages/app/src/js/components/pages/Search.tsx @@ -2,7 +2,7 @@ import styled from 'styled-components'; import { useCallback, useState } from 'react'; import { useStream } from '../contexts/useStream'; import { HoverProvider } from '../contexts/hover'; -import { useSelector, useDispatch } from 'react-redux'; +import { useSelector, useDispatch } from '../../store'; import { formatTime, formatDate } from '../../utils'; import { SearchBox } from '../atoms/SearchBox'; @@ -108,7 +108,7 @@ export const Header = () => { const [value, setValue] = useState(''); const submit = useCallback(async () => { - dispatch.methods.search.find(stream.channelId, value); + dispatch.methods.search.find({channelId: stream.channelId, text: value}); }, [dispatch, stream, value]); const onKeyDown= useCallback(async (e: React.KeyboardEvent) => { diff --git a/packages/app/src/js/components/pages/Workspace.tsx b/packages/app/src/js/components/pages/Workspace.tsx index e8f60f40..2d6f04f1 100644 --- a/packages/app/src/js/components/pages/Workspace.tsx +++ b/packages/app/src/js/components/pages/Workspace.tsx @@ -1,4 +1,4 @@ -import { useDispatch, useSelector } from 'react-redux'; +import { useDispatch, useSelector } from '../../store'; import { MainConversation } from '../organisms/MainConversaion'; import { SideConversation } from '../organisms/SideConversation'; diff --git a/packages/app/src/js/hooks/index.js b/packages/app/src/js/hooks/index.ts similarity index 100% rename from packages/app/src/js/hooks/index.js rename to packages/app/src/js/hooks/index.ts diff --git a/packages/app/src/js/hooks/useEmoji.ts b/packages/app/src/js/hooks/useEmoji.ts index f9240e26..40084d86 100644 --- a/packages/app/src/js/hooks/useEmoji.ts +++ b/packages/app/src/js/hooks/useEmoji.ts @@ -1,5 +1,5 @@ import { useMemo, useEffect } from 'react'; -import { useSelector, useDispatch } from 'react-redux'; +import { useDispatch, useSelector } from '../store'; type Emoji = { shortname: string; @@ -10,16 +10,16 @@ type Emoji = { export const useEmoji = (shortname: string): Emoji => { const dispatch = useDispatch(); - const emojis = useSelector((state: any) => state.emojis); + const emojis = useSelector((state) => state.emojis); const emoji = useMemo( () => emojis.data.find((emoji: Emoji) => emoji.shortname === shortname), [emojis, shortname], ); useEffect(() => { if (!emoji && emojis.ready) { - (dispatch as any).methods.emojis.find(shortname); + dispatch.methods.emojis.find(shortname); } }, [dispatch, emoji, shortname, emojis]); - return emoji; + return emoji ?? { shortname, empty: true}; }; diff --git a/packages/app/src/js/services/file.js b/packages/app/src/js/services/file.js index 77b7430b..e61bb81c 100644 --- a/packages/app/src/js/services/file.js +++ b/packages/app/src/js/services/file.js @@ -6,12 +6,12 @@ const tempId = createCounter(`file:${(Math.random() + 1).toString(36)}`); const FILES_URL = `${API_URL}/files`; -export const uploadMany = (streamId, files) => async (dispatch) => { +export const uploadMany = (streamId, files) => ({type: 'async', handler: async (dispatch) => { for (let i = 0, file; i < files.length; i++) { file = files.item(i); dispatch(upload(streamId, file)); } -}; +}}); export const upload = (streamId, file) => async (dispatch) => { const local = { diff --git a/packages/app/src/js/services/messages.js b/packages/app/src/js/services/messages.ts similarity index 96% rename from packages/app/src/js/services/messages.js rename to packages/app/src/js/services/messages.ts index eb43287b..efe24167 100644 --- a/packages/app/src/js/services/messages.js +++ b/packages/app/src/js/services/messages.ts @@ -1,16 +1,17 @@ import { client } from '../core'; import { createCounter } from '../utils'; +import { createAsyncAction } from '../store'; const tempId = createCounter(`temp:${(Math.random() + 1).toString(36)}`); -const loading = (dispatch) => { - dispatch.actions.messages.loading(); - const timer = setTimeout(() => dispatch.actions.messages.loadingDone(), 1000); +const loading = createAsyncAction(async (dispatch) => { + dispatch.actions.messages.loading({}); + const timer = setTimeout(() => dispatch.actions.messages.loadingDone({}), 1000); return () => { - dispatch.actions.messages.loadingDone(); + dispatch.actions.messages.loadingDone({}); clearTimeout(timer); }; -}; +}); const getStreamMessages = (stream, messages) => messages .filter((m) => m.channelId === stream.channelId @@ -119,9 +120,9 @@ export const sendFromDom = (stream, dom) => async (dispatch, getState) => { } }; -export const send = (stream, msg) => (dispatch) => dispatch(msg.type === 'command:execute' ? sendCommand(stream, msg) : sendMessage(msg)); +export const send = (stream, msg) => createAsyncAction((dispatch) => dispatch(msg.type === 'command:execute' ? sendCommand(stream, msg) : sendMessage(msg))); -export const sendShareMessage = (data) => async (dispatch, getState) => { +export const sendShareMessage = (data) => createAsyncAction(async (dispatch, getState) => { const { channelId, parentId } = getState().stream.main; const info = { links: [] }; const msg = build({ @@ -147,7 +148,7 @@ export const sendShareMessage = (data) => async (dispatch, getState) => { }, }); } -}; +}); const buildShareMessage = (data, info) => { const lines = []; diff --git a/packages/app/src/js/store/components/command.tsx b/packages/app/src/js/store/components/command.tsx new file mode 100644 index 00000000..616f5c01 --- /dev/null +++ b/packages/app/src/js/store/components/command.tsx @@ -0,0 +1,18 @@ +import { + createContext, +} from 'react'; + +export const CommandContext = createContext(undefined); + +type CommandContextProps = { + children: React.ReactNode; + value: any; +}; + +export const CommandProvider = ({ children, value }: CommandContextProps) => { + return ( + + {children} + + ); +}; diff --git a/packages/app/src/js/store/components/provider.tsx b/packages/app/src/js/store/components/provider.tsx new file mode 100644 index 00000000..81df3859 --- /dev/null +++ b/packages/app/src/js/store/components/provider.tsx @@ -0,0 +1,15 @@ +import { Provider } from 'react-redux'; +import { store } from '../store'; + +type ProviderProps = { + children: React.ReactNode; +}; + +const StoreProvider = ({children}: ProviderProps) => ( + + {children} + +); + +export default StoreProvider; + diff --git a/packages/app/src/js/store/hooks/useCommand.ts b/packages/app/src/js/store/hooks/useCommand.ts new file mode 100644 index 00000000..c8bc503d --- /dev/null +++ b/packages/app/src/js/store/hooks/useCommand.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react'; +import {CommandContext} from '../components/command'; + +export const useCommand = () => { + return useContext(CommandContext); +}; diff --git a/packages/app/src/js/store/hooks/useDispatch.ts b/packages/app/src/js/store/hooks/useDispatch.ts new file mode 100644 index 00000000..24c57bf9 --- /dev/null +++ b/packages/app/src/js/store/hooks/useDispatch.ts @@ -0,0 +1,4 @@ +import * as reactRedux from 'react-redux'; +import {DispatchType} from '../store'; + +export const useDispatch = reactRedux.useDispatch.withTypes(); diff --git a/packages/app/src/js/store/hooks/useSelector.ts b/packages/app/src/js/store/hooks/useSelector.ts new file mode 100644 index 00000000..75cec550 --- /dev/null +++ b/packages/app/src/js/store/hooks/useSelector.ts @@ -0,0 +1,4 @@ +import * as reactRedux from 'react-redux'; +import {StateType} from '../store'; + +export const useSelector = reactRedux.useSelector.withTypes(); diff --git a/packages/app/src/js/store/hooks/useStore.ts b/packages/app/src/js/store/hooks/useStore.ts new file mode 100644 index 00000000..14fd607e --- /dev/null +++ b/packages/app/src/js/store/hooks/useStore.ts @@ -0,0 +1,4 @@ +import * as reactRedux from 'react-redux'; +import {StoreType} from '../store'; + +export const useStore = () => reactRedux.useStore(); diff --git a/packages/app/src/js/store/index.js b/packages/app/src/js/store/index.js deleted file mode 100644 index ee2b7760..00000000 --- a/packages/app/src/js/store/index.js +++ /dev/null @@ -1,52 +0,0 @@ -import { - createStore, applyMiddleware, compose, -} from 'redux'; -import { client } from '../core'; -import * as modules from './modules'; - -const middleware = ({ dispatch, getState }) => (next) => async (action) => { - if (typeof action === 'function') { - dispatch.actions = actions; - dispatch.methods = methods; - try { - return await action(dispatch, getState, {client}); - } catch (err) { - // eslint-disable-next-line no-console - console.error(err); - return; - } - } - - return next(action); -} - -export const store = createStore( - modules.reducer, - window.__REDUX_DEVTOOLS_EXTENSION__ - ? compose( - applyMiddleware(middleware), - window.__REDUX_DEVTOOLS_EXTENSION__ && window.__REDUX_DEVTOOLS_EXTENSION__(), - ) - : applyMiddleware(middleware), -); - -window.store = store; - -export const actions = Object.keys(modules.actions).reduce((acc, module) => { - acc[module] = Object.keys(modules.actions[module]).reduce((acc2, action) => { - acc2[action] = (data) => store.dispatch(modules.actions[module][action](data)); - return acc2; - }, {}); - return acc; -}, {}); - -export const methods = Object.keys(modules.methods).reduce((acc, module) => { - acc[module] = Object.keys(modules.methods[module]).reduce((acc2, action) => { - acc2[action] = (...props) => store.dispatch(modules.methods[module][action](...props)); - return acc2; - }, {}); - return acc; -}, {}); - -store.dispatch.actions = actions; -store.dispatch.methods = methods; diff --git a/packages/app/src/js/store/index.ts b/packages/app/src/js/store/index.ts new file mode 100644 index 00000000..0ff383e1 --- /dev/null +++ b/packages/app/src/js/store/index.ts @@ -0,0 +1,9 @@ +export * from './store'; +export * from './hooks/useCommand'; +export * from './hooks/useDispatch'; +export * from './hooks/useSelector'; +export * from './hooks/useStore'; + + + + diff --git a/packages/app/src/js/store/modules/channels.js b/packages/app/src/js/store/modules/channels.js deleted file mode 100644 index ced755e1..00000000 --- a/packages/app/src/js/store/modules/channels.js +++ /dev/null @@ -1,38 +0,0 @@ -import {createModule} from '../tools'; - -export default createModule({ - name: 'channels', - initialState: {}, - reducers: { - add: (state, action) => { - const newState = {...state}; - [action.payload].flat().forEach((channel) => { - newState[channel.id] = Object.assign(newState[channel.id] || {}, channel); - }); - return newState; - }, - remove: (state, action) => { - const id = action.payload; - const newState = {...state}; - delete newState[id]; - return newState; - }, - }, - methods: { - load: () => async ({actions}, getState, {client}) => { - const res = await client.req({ type: 'channel:getAll' }); - actions.channels.add(res.data); - }, - create: ({channelType, name, users}) => async ({actions}, getState, {client}) => { - const res = await client.req({ - type: 'channel:create', channelType, name, users, - }); - actions.channels.add(res.data); - }, - find: (id) => async ({actions}, getState, {client}) => { - const res = await client.req({ type: 'channel:get', id }); - actions.channels.add(res.data); - return res.data; - }, - }, -}); diff --git a/packages/app/src/js/store/modules/config.js b/packages/app/src/js/store/modules/config.js deleted file mode 100644 index 8b87c2a1..00000000 --- a/packages/app/src/js/store/modules/config.js +++ /dev/null @@ -1,16 +0,0 @@ -import {createModule} from '../tools'; - -export default createModule({ - name: 'config', - initialState: {}, - reducers: { - setAppVersion: (state, action) => ({...state, appVersion: action.payload }), - }, - methods: { - load: () => async ({ actions }, getState, { client }) => { - const { data: [config] } = await client.req({ type: 'user:config' }); - actions.config.setAppVersion(config.appVersion); - return config; - }, - }, -}); diff --git a/packages/app/src/js/store/modules/connection.js b/packages/app/src/js/store/modules/connection.js deleted file mode 100644 index 8dc6fc78..00000000 --- a/packages/app/src/js/store/modules/connection.js +++ /dev/null @@ -1,10 +0,0 @@ -import {createModule} from '../tools'; - -export default createModule({ - name: 'connection', - initialState: false, - reducers: { - connected: () => true, - disconnected: () => false, - }, -}); diff --git a/packages/app/src/js/store/modules/files.js b/packages/app/src/js/store/modules/files.js deleted file mode 100644 index 1aaf51e6..00000000 --- a/packages/app/src/js/store/modules/files.js +++ /dev/null @@ -1,27 +0,0 @@ -import {createModule} from '../tools'; - -export const findIdx = (list, id) => list.findIndex((f) => (f.id && f.id === id) - || (f.clientId && f.clientId === id)); - -export default createModule({ - name: 'files', - initialState: [], - reducers: { - add: (state, action) => ([...state, ...[action.payload].flat()]), - update: (state, action) => { - const idx = findIdx(state, action.payload.id); - if (idx === -1) return state; - const newState = [...state]; - newState[idx] = { ...newState[idx], ...action.payload.file}; - return newState; - }, - remove: (state, action) => { - const idx = findIdx(state, action.payload); - if (idx === -1) return; - const newState = [...state]; - newState.splice(idx, 1); - return newState; - }, - clear: (state, action) => state.filter((f) => f.streamId !== action.payload), - }, -}); diff --git a/packages/app/src/js/store/modules/index.js b/packages/app/src/js/store/modules/index.js deleted file mode 100644 index d6c6bbc2..00000000 --- a/packages/app/src/js/store/modules/index.js +++ /dev/null @@ -1,51 +0,0 @@ -import {combineReducers} from 'redux'; -import channels from './channels'; -import me from './me'; -import users from './users'; -import stream from './stream'; -import messages from './messages'; -import emojis from './emojis'; -import config from './config'; -import connection from './connection'; -import system from './system'; -import info from './info'; -import progress from './progress'; -import view from './view'; -import files from './files'; -import typing from './typing'; -import pins from './pins'; -import search from './search'; - -const MODULES = { - channels, - me, - users, - stream, - messages, - emojis, - config, - connection, - system, - info, - progress, - view, - files, - typing, - pins, - search, -}; - -export const reducer = combineReducers(Object.entries(MODULES).reduce((acc, [name, value]) => ({ - ...acc, - [name]: value.reducer, -}), {})); - -export const actions = Object.entries(MODULES).reduce((acc, [name, value]) => ({ - ...acc, - [name]: value.actions, -}), {}); - -export const methods = Object.entries(MODULES).reduce((acc, [name, value]) => ({ - ...acc, - [name]: value.methods, -}), {}); diff --git a/packages/app/src/js/store/modules/info.js b/packages/app/src/js/store/modules/info.js deleted file mode 100644 index e9d04b94..00000000 --- a/packages/app/src/js/store/modules/info.js +++ /dev/null @@ -1,9 +0,0 @@ -import {createModule} from '../tools'; - -export default createModule({ - name: 'info', - initialState: { type: null, message: '' }, - reducers: { - show: (state, action) => action.payload, - }, -}); diff --git a/packages/app/src/js/store/modules/me.js b/packages/app/src/js/store/modules/me.js deleted file mode 100644 index c4a63c63..00000000 --- a/packages/app/src/js/store/modules/me.js +++ /dev/null @@ -1,9 +0,0 @@ -import {createModule} from '../tools'; - -export default createModule({ - name: 'me', - initialState: null, - reducers: { - set: (state, action) => action.payload, - }, -}); diff --git a/packages/app/src/js/store/modules/system.js b/packages/app/src/js/store/modules/system.js deleted file mode 100644 index c696ca4c..00000000 --- a/packages/app/src/js/store/modules/system.js +++ /dev/null @@ -1,9 +0,0 @@ -import {createModule} from '../tools'; - -export default createModule({ - name: 'system', - initialState: { initFailed: false }, - reducers: { - initFailed: (state, action) => ({ ...state, initFailed: action.payload }), - }, -}); diff --git a/packages/app/src/js/store/modules/users.js b/packages/app/src/js/store/modules/users.js deleted file mode 100644 index 4fcd58ab..00000000 --- a/packages/app/src/js/store/modules/users.js +++ /dev/null @@ -1,21 +0,0 @@ -import {createModule} from '../tools'; - -export default createModule({ - name: 'users', - initialState: {}, - reducers: { - add: (state, action) => { - const newState = {...state}; - [action.payload].flat().forEach((user) => { - newState[user.id] = Object.assign(newState[user.id] || {}, user); - }); - return newState; - }, - }, - methods: { - load: () => async ({actions}, getState, {client}) => { - const res = await client.req({ type: 'user:getAll' }); - actions.users.add(res.data); - }, - }, -}); diff --git a/packages/app/src/js/store/modules/view.js b/packages/app/src/js/store/modules/view.js deleted file mode 100644 index 2ebed615..00000000 --- a/packages/app/src/js/store/modules/view.js +++ /dev/null @@ -1,15 +0,0 @@ -import {createModule} from '../tools'; - -export default createModule({ - name: 'view', - initialState: {current: null}, - reducers: { - set: (state, action) => { - const view = action.payload; - if (state.current === view) { - return {current: null}; - } - return {current: view}; - }, - }, -}); diff --git a/packages/app/src/js/store/slices/channels.ts b/packages/app/src/js/store/slices/channels.ts new file mode 100644 index 00000000..843209f4 --- /dev/null +++ b/packages/app/src/js/store/slices/channels.ts @@ -0,0 +1,53 @@ +import { PayloadAction, createSlice } from '@reduxjs/toolkit'; +import { createMethods } from '../tools'; + +type Channel = { + id: string; + name: string; + users: string[]; + priv?: boolean; +}; + +const slice = createSlice({ + name: 'channels', + initialState: {} as {[id: string]: Channel}, + reducers: { + add: (state, action: PayloadAction) => { + const payload = action.payload; + const newState = {...state}; + [payload as Channel | Channel[]].flat().forEach((channel) => { + newState[channel.id] = Object.assign(newState[channel.id] || {}, channel); + }); + return newState; + }, + remove: (state, action: PayloadAction) => { + const id = action.payload; + const newState = {...state}; + delete newState[id]; + return newState; + }, + }, +}); + +export const methods = createMethods({ + module_name: 'channels', + methods: { + load: async (_arg, {dispatch: {actions}},{client}) => { + const res = await client.req({ type: 'channel:getAll' }); + actions.channels.add(res.data); + }, + create: async ({channelType, name, users}, {dispatch: {actions}}, {client}) => { + const res = await client.req({ + type: 'channel:create', channelType, name, users, + }); + actions.channels.add(res.data); + }, + find: async (id, {dispatch: {actions}},{client}) => { + const res = await client.req({ type: 'channel:get', id }); + actions.channels.add(res.data); + return res.data; + }, + }, +}); + +export const { reducer, actions } = slice; diff --git a/packages/app/src/js/store/slices/config.ts b/packages/app/src/js/store/slices/config.ts new file mode 100644 index 00000000..3fb33e51 --- /dev/null +++ b/packages/app/src/js/store/slices/config.ts @@ -0,0 +1,27 @@ +import { createSlice } from '@reduxjs/toolkit'; +import {createMethods} from '../tools'; + +type ConfigState = { + appVersion: string, +}; + +const slice = createSlice({ + name: 'config', + initialState: {} as ConfigState, + reducers: { + setAppVersion: (state, action) => ({...state, appVersion: action.payload }), + }, +}); + +export const methods = createMethods({ + module_name: 'config', + methods: { + load: async (_arg, {dispatch: { actions }}, { client }) => { + const { data: [config] } = await client.req({ type: 'user:config' }); + actions.config.setAppVersion(config.appVersion); + return config; + }, + }, +}); + +export const { reducer, actions } = slice; diff --git a/packages/app/src/js/store/slices/connection.ts b/packages/app/src/js/store/slices/connection.ts new file mode 100644 index 00000000..cda63760 --- /dev/null +++ b/packages/app/src/js/store/slices/connection.ts @@ -0,0 +1,15 @@ +import { createSlice } from '@reduxjs/toolkit'; + +type ConnectionState = boolean; + +const slice = createSlice({ + name: 'connection', + initialState: false as ConnectionState, + reducers: { + connected: () => true, + disconnected: () => false, + }, +}); + +export const methods = {}; +export const { reducer, actions } = slice; diff --git a/packages/app/src/js/store/modules/emojis.js b/packages/app/src/js/store/slices/emojis.ts similarity index 62% rename from packages/app/src/js/store/modules/emojis.js rename to packages/app/src/js/store/slices/emojis.ts index 511da82b..03b73d64 100644 --- a/packages/app/src/js/store/modules/emojis.js +++ b/packages/app/src/js/store/slices/emojis.ts @@ -1,8 +1,15 @@ -import {createModule} from '../tools'; +import { createSlice } from '@reduxjs/toolkit'; +import { createMethods } from '../tools'; +import { EmojiDescriptor } from '../../types'; -export default createModule({ +type EmojiState = { + ready: boolean; + data: EmojiDescriptor[]; +}; + +const slice = createSlice({ name: 'emojis', - initialState: {ready: false, data: []}, + initialState: {ready: false, data: []} as EmojiState, reducers: { ready: (state) => ({...state, ready: true}), add: (state, action) => { @@ -18,18 +25,22 @@ export default createModule({ return newState; }, }, +}); + +export const methods = createMethods({ + module_name: 'emojis', methods: { - load: () => async ({actions}, getState, {client}) => { + load: async (_arg, {dispatch: {actions}}, {client}) => { const [baseEmojis, { data: emojis }] = await Promise.all([ import('../../../assets/emoji_list.json'), client.req({ type: 'emoji:getAll' }), ]); actions.emojis.add(baseEmojis.default); - actions.emojis.add(emojis.map((e) => ({...e, category: 'c'}))); + actions.emojis.add(emojis.map((e: any) => ({...e, category: 'c'}))); actions.emojis.ready({}); }, - find: (shortname) => async ({actions}, getState, {client}) => { + find: async (shortname, {dispatch: {actions}}, {client}) => { try { const { data: [emoji] } = await client.req({ type: 'emoji:get', shortname }); if (emoji) actions.emojis.add(emoji); @@ -39,3 +50,6 @@ export default createModule({ }, }, }); + + +export const { actions, reducer } = slice; diff --git a/packages/app/src/js/store/slices/files.ts b/packages/app/src/js/store/slices/files.ts new file mode 100644 index 00000000..93245765 --- /dev/null +++ b/packages/app/src/js/store/slices/files.ts @@ -0,0 +1,44 @@ +import { createSlice } from '@reduxjs/toolkit'; + +type File = { + id: string; + clientId: string; + streamId: string; + status: string; + file: { + name: string; + type: string; + size: number; + }; +}; + +export const findIdx = (list: File[], id: string) => list.findIndex((f) => (f.id && f.id === id) + || (f.clientId && f.clientId === id)); + +const slice = createSlice({ + name: 'files', + initialState: [] as File[], + reducers: { + add: (state, action) => ([...state, ...[action.payload].flat()] as File[]), + update: (state, action) => { + const payload = (action.payload as File); + const idx = findIdx(state, payload.id); + if (idx === -1) return state; + const newState = [...state]; + newState[idx] = { ...newState[idx], ...payload.file}; + return newState; + }, + remove: (state, action) => { + const id = (action.payload as string); + const idx = findIdx(state, id); + if (idx === -1) return state; + const newState = [...state]; + newState.splice(idx, 1); + return newState; + }, + clear: (state, action) => state.filter((f) => f.streamId !== action.payload), + }, +}); + +export const methods = {}; +export const { reducer, actions } = slice; diff --git a/packages/app/src/js/store/slices/index.ts b/packages/app/src/js/store/slices/index.ts new file mode 100644 index 00000000..37ea3a91 --- /dev/null +++ b/packages/app/src/js/store/slices/index.ts @@ -0,0 +1,44 @@ +import * as channels from './channels'; +import * as me from './me'; +import * as users from './users'; +import * as stream from './stream'; +import * as messages from './messages'; +import * as emojis from './emojis'; +import * as config from './config'; +import * as connection from './connection'; +import * as system from './system'; +import * as info from './info'; +import * as progress from './progress'; +import * as view from './view'; +import * as files from './files'; +import * as typing from './typing'; +import * as pins from './pins'; +import * as search from './search'; + +const modules = { + channels, + me, + users, + stream, + messages, + emojis, + config, + connection, + system, + info, + progress, + view, + files, + typing, + pins, + search, +}; +type Modules = typeof modules; + +type Reducers = { [K in keyof T]: T[K]['reducer'] }; +type Methods = { [K in keyof T]: T[K]['methods'] }; +type Actions = { [K in keyof T]: T[K]['actions'] }; + +export const reducers = Object.fromEntries(Object.entries(modules).map(([name, {reducer}]) => [name, reducer])) as Reducers; +export const methods = Object.fromEntries(Object.entries(modules).map(([name, {methods}]) => [name, methods])) as Methods; +export const actions = Object.fromEntries(Object.entries(modules).map(([name, {actions}]) => [name, actions])) as Actions; diff --git a/packages/app/src/js/store/slices/info.ts b/packages/app/src/js/store/slices/info.ts new file mode 100644 index 00000000..ba8277e1 --- /dev/null +++ b/packages/app/src/js/store/slices/info.ts @@ -0,0 +1,17 @@ +import { PayloadAction, createSlice } from '@reduxjs/toolkit'; + +type InfoState = { + type: string | null; + message: string; +}; + +const slice = createSlice({ + name: 'info', + initialState: { type: null, message: '' } as InfoState, + reducers: { + show: (_state, action: PayloadAction) => action.payload, + }, +}); + +export const methods = {}; +export const { reducer, actions } = slice; diff --git a/packages/app/src/js/store/slices/me.ts b/packages/app/src/js/store/slices/me.ts new file mode 100644 index 00000000..47e904fc --- /dev/null +++ b/packages/app/src/js/store/slices/me.ts @@ -0,0 +1,14 @@ +import { PayloadAction, createSlice } from '@reduxjs/toolkit'; + +type MeState = string | null; + +const slice = createSlice({ + name: 'me', + initialState: null as MeState, + reducers: { + set: (_state, action: PayloadAction) => action.payload, + }, +}); + +export const methods = {}; +export const { reducer, actions } = slice; diff --git a/packages/app/src/js/store/modules/messages.js b/packages/app/src/js/store/slices/messages.ts similarity index 82% rename from packages/app/src/js/store/modules/messages.js rename to packages/app/src/js/store/slices/messages.ts index f18f1f95..c2f3713f 100644 --- a/packages/app/src/js/store/modules/messages.js +++ b/packages/app/src/js/store/slices/messages.ts @@ -1,16 +1,26 @@ -import {createModule} from '../tools'; +import { Message as MessageType } from '../../types'; +import { PayloadAction } from '../types'; +import { createSlice } from '@reduxjs/toolkit'; +import { createMethods } from '../tools'; -export default createModule({ +type MessagesState = { + data: MessageType[]; + loading: boolean; + status: 'live' | 'archived'; + hovered: string | null; +}; + +const slice = createSlice({ name: 'messages', initialState: { data: [], loading: false, status: 'live', hovered: null, - }, + } as MessagesState, reducers: { - hover: (state, action) => ({...state, hovered: action.payload}), - setStatus: (state, action) => ({...state, status: action.payload}), + hover: (state, action: PayloadAction) => ({...state, hovered: action.payload ?? null}), + setStatus: (state, action) => ({...state, status: action.payload as 'live' | 'archived'}), loadingFailed: (state, action) => ({...state, loadingFailed: action.payload}), loading: (state) => ({...state, loading: true}), loadingDone: (state) => ({...state, loading: false}), @@ -95,9 +105,12 @@ export default createModule({ return state; }, }, +}); +export const methods = createMethods({ + module_name: 'messages', methods: { - load: (query) => async ({actions}, getState, { client }) => { + load: async (query, {dispatch: {actions}}, { client }) => { const req = await client.req({ limit: 50, ...query, @@ -106,13 +119,15 @@ export default createModule({ actions.messages.add(req.data); return req.data; }, - addReaction: (id, text) => async ({actions}, getState, { client }) => { + addReaction: async (args, {dispatch: {actions}}, { client }) => { const req = await client.req({ type: 'message:react', - id, - reaction: text.trim(), + id: args.id, + reaction: args.text.trim(), }); actions.messages.add(req.data); }, - }, + } }); + +export const { reducer, actions } = slice; diff --git a/packages/app/src/js/store/modules/pins.js b/packages/app/src/js/store/slices/pins.ts similarity index 74% rename from packages/app/src/js/store/modules/pins.js rename to packages/app/src/js/store/slices/pins.ts index 0dc954af..93352647 100644 --- a/packages/app/src/js/store/modules/pins.js +++ b/packages/app/src/js/store/slices/pins.ts @@ -1,8 +1,14 @@ -import {createModule} from '../tools'; +import { createSlice } from '@reduxjs/toolkit'; +import { Message } from '../../types'; +import { createMethods } from '../tools'; -export default createModule({ +type PinsState = { + [channelId: string]: Message[]; +}; + +const slice = createSlice({ name: 'pins', - initialState: {}, + initialState: {} as PinsState, reducers: { add: (state, action) => { const newState = { ...state }; @@ -32,8 +38,12 @@ export default createModule({ return newState; }, }, +}); + +export const methods = createMethods({ + module_name: 'pins', methods: { - load: (channelId) => async ({actions}, getState, {client}) => { + load: async (channelId, {dispatch: {actions}}, {client}) => { actions.pins.clear(channelId); const req = await client.req({ type: 'message:pins', @@ -42,7 +52,7 @@ export default createModule({ }); actions.pins.add(req.data); }, - pin: (id, channelId) => async ({actions, methods}, getState, {client}) => { + pin: async ({id, channelId}, {dispatch: {actions, methods}}, {client}) => { const req = await client.req({ type: 'message:pin', channelId, @@ -53,7 +63,7 @@ export default createModule({ await methods.pins.load(channelId); }, - unpin: (id, channelId) => async ({actions, methods}, getState, {client}) => { + unpin: async ({id, channelId}, {dispatch: {actions, methods}}, {client}) => { const req = await client.req({ type: 'message:pin', channelId, @@ -65,3 +75,6 @@ export default createModule({ }, }, }); + +export const { reducer, actions } = slice; + diff --git a/packages/app/src/js/store/modules/progress.js b/packages/app/src/js/store/slices/progress.ts similarity index 68% rename from packages/app/src/js/store/modules/progress.js rename to packages/app/src/js/store/slices/progress.ts index 1c9a9a7f..968fd550 100644 --- a/packages/app/src/js/store/modules/progress.js +++ b/packages/app/src/js/store/slices/progress.ts @@ -1,8 +1,15 @@ -import {createModule} from '../tools'; +import { createSlice } from "@reduxjs/toolkit"; +import { createMethods } from "../tools"; -export default createModule({ +type ProgressState = { + channelId: string; + userId: string; + parentId: string; +}; + +const slice = createSlice({ name: 'progress', - initialState: [], + initialState: [] as ProgressState[], reducers: { add: (state, action) => { const newState = [...state]; @@ -18,15 +25,19 @@ export default createModule({ return newState; }, }, +}); + +export const methods = createMethods({ + module_name: 'progress', methods: { - loadBadges: () => async ({actions}, getState, {client}) => { + loadBadges: async (_arg, {dispatch: {actions}}, {client}) => { const { data } = await client.req({ type: 'readReceipt:getOwn', }); actions.progress.add(data); }, - loadProgress: (stream) => async ({actions}, getState, {client}) => { + loadProgress: async (stream, {dispatch: {actions}}, {client}) => { try{ if (!stream.channelId) return; const { data } = await client.req({ @@ -41,7 +52,7 @@ export default createModule({ } }, - update: (messageId) => async ({actions}, getState, {client}) => { + update: async (messageId, {dispatch: {actions}}, {client}) => { const { data } = await client.req({ type: 'readReceipt:update', messageId, @@ -50,3 +61,5 @@ export default createModule({ }, }, }); + +export const { reducer, actions } = slice; diff --git a/packages/app/src/js/store/modules/search.js b/packages/app/src/js/store/slices/search.ts similarity index 50% rename from packages/app/src/js/store/modules/search.js rename to packages/app/src/js/store/slices/search.ts index cae6a079..edc83ac3 100644 --- a/packages/app/src/js/store/modules/search.js +++ b/packages/app/src/js/store/slices/search.ts @@ -1,17 +1,30 @@ -import {createModule} from '../tools'; +import { createSlice } from "@reduxjs/toolkit"; +import { createMethods } from "../tools"; +import { Message } from "../../types"; -export default createModule({ +type SearchState = { + results: Message[]; + text: string; +}; + +const slice = createSlice({ name: 'search', - initialState: {results: [], text: ''}, + initialState: {results: [], text: ''} as SearchState, reducers: { push: (state, action) => ({...state, results: [action.payload, ...state.results.slice(0, 5)]}), set: (state, action) => ({...state, text: action.payload}), clear: () => ({results: [], text: ''}), }, +}); + +export const methods = createMethods({ + module_name: 'search', methods: { - find: (channelId, text) => async ({actions}, getState, {client}) => { + find: async ({channelId, text}, {dispatch: {actions}}, {client}) => { const data = await client.req({ type: 'message:search', channelId, text }); actions.search.push({ text, data: data.data, searchedAt: new Date().toISOString() }); }, }, }); + +export const { reducer, actions } = slice; diff --git a/packages/app/src/js/store/modules/stream.js b/packages/app/src/js/store/slices/stream.ts similarity index 84% rename from packages/app/src/js/store/modules/stream.js rename to packages/app/src/js/store/slices/stream.ts index aa74b4f5..c6a1eed6 100644 --- a/packages/app/src/js/store/modules/stream.js +++ b/packages/app/src/js/store/slices/stream.ts @@ -1,7 +1,6 @@ -import {createModule} from '../tools'; +import { createSlice } from '@reduxjs/toolkit'; import { omitUndefined } from '../../utils'; -/* type Stream = { channelId: string, parentId: string, @@ -10,7 +9,12 @@ type Stream = { selected: string, date: Date, }; -*/ + +type StreamState = { + main: Stream, + side: Stream, + mainChannelId: string, +}; const loadStream = () => { const { hash } = document.location; @@ -32,7 +36,7 @@ const loadStream = () => { }; }; -const saveStream = (stream) => { +const saveStream = (stream: Stream) => { const query = new URLSearchParams(omitUndefined({ type: stream.type, date: stream.date, @@ -45,9 +49,9 @@ const saveStream = (stream) => { + (querystring ? `?${querystring}` : ''); }; -export default createModule({ +const slice = createSlice({ name: 'stream', - initialState: { main: loadStream(), side: null, mainChannelId: null }, + initialState: { main: loadStream(), side: null, mainChannelId: null } as StreamState, reducers: { open: (state, action) => { const { id, value } = action.payload; @@ -64,3 +68,6 @@ export default createModule({ }, }, }); + +export const methods = {}; +export const { actions, reducer } = slice; diff --git a/packages/app/src/js/store/slices/system.ts b/packages/app/src/js/store/slices/system.ts new file mode 100644 index 00000000..8c420793 --- /dev/null +++ b/packages/app/src/js/store/slices/system.ts @@ -0,0 +1,16 @@ +import { createSlice } from "@reduxjs/toolkit"; + +type SystemState = { + initFailed: boolean; +}; + +const slice = createSlice({ + name: 'system', + initialState: { initFailed: false } as SystemState, + reducers: { + initFailed: (state, action) => ({ ...state, initFailed: action.payload }), + }, +}); + +export const methods = {}; +export const { actions, reducer } = slice; diff --git a/packages/app/src/js/store/modules/typing.js b/packages/app/src/js/store/slices/typing.ts similarity index 69% rename from packages/app/src/js/store/modules/typing.js rename to packages/app/src/js/store/slices/typing.ts index 32723892..165768eb 100644 --- a/packages/app/src/js/store/modules/typing.js +++ b/packages/app/src/js/store/slices/typing.ts @@ -1,8 +1,18 @@ -import {createModule} from '../tools'; +import { createSlice } from "@reduxjs/toolkit"; +import { createMethods } from "../tools"; -export default createModule({ +type TypingState = { + [channelId: string]: { + [userId: string]: string; + }; +} & { + cooldown: boolean; + queue: boolean; +}; + +const slice = createSlice({ name: 'typing', - initialState: { cooldown: false, queue: false }, + initialState: { cooldown: false, queue: false } as TypingState, reducers: { add: (state, action) => { const newState = {...state}; @@ -12,7 +22,7 @@ export default createModule({ }); return newState; }, - clear: (state) => ({ + clear: (state, _action) => ({ ...Object.fromEntries( Object.entries(state) .map(([channelId, users]) => [ @@ -28,14 +38,18 @@ export default createModule({ }), set: (state, action) => ({...state, ...action.payload}), }, +}); + +export const methods = createMethods({ + module_name: 'typing', methods: { - ack: (msg) => (dispatch, getState) => { + ack: async (msg, {dispatch, getState}) => { const meId = getState().me; if (msg.userId === meId) return; dispatch.actions.typing.add(msg); setTimeout(() => dispatch.actions.typing.clear(), 1100); }, - notify: ({channelId, parentId}) => ({ actions, methods }, getState, {client}) => { + notify: () => ({channelId, parentId}, {dispatch: { actions, methods }, getState}, {client}) => { const {cooldown} = getState().typing; if (cooldown) { actions.typing.set({ queue: true }); @@ -52,3 +66,5 @@ export default createModule({ }, }, }); + +export const { reducer, actions } = slice; diff --git a/packages/app/src/js/store/slices/users.ts b/packages/app/src/js/store/slices/users.ts new file mode 100644 index 00000000..3209c6b0 --- /dev/null +++ b/packages/app/src/js/store/slices/users.ts @@ -0,0 +1,35 @@ +import { createSlice } from '@reduxjs/toolkit'; +import { createMethods } from '../tools'; + +type User = { + id: string; + name: string; + email: string; + avatarUrl: string; +}; + +const slice = createSlice({ + name: 'users', + initialState: {} as Record, + reducers: { + add: (state, action) => { + const newState = {...state}; + ([action.payload] as User[]).flat().forEach((user) => { + newState[user.id] = Object.assign(newState[user.id] || {}, user); + }); + return newState; + }, + }, +}); + +export const methods = createMethods({ + module_name: 'users', + methods: { + load: async (_arg, {dispatch: {actions}}, {client}) => { + const res = await client.req({ type: 'user:getAll' }); + actions.users.add(res.data); + }, + }, +}); + +export const { reducer, actions } = slice; diff --git a/packages/app/src/js/store/slices/view.ts b/packages/app/src/js/store/slices/view.ts new file mode 100644 index 00000000..f75467e3 --- /dev/null +++ b/packages/app/src/js/store/slices/view.ts @@ -0,0 +1,23 @@ +import { createSlice } from "@reduxjs/toolkit"; + +type ViewState = { + current: string | null; +}; + +const slice = createSlice({ + name: 'view', + initialState: {current: null} as ViewState, + reducers: { + set: (state, action) => { + const view = action.payload; + if (state.current === view) { + return {current: null}; + } + return {current: view}; + }, + }, +}); + + +export const methods = {}; +export const { actions, reducer } = slice; diff --git a/packages/app/src/js/store/store.ts b/packages/app/src/js/store/store.ts new file mode 100644 index 00000000..ed4b6338 --- /dev/null +++ b/packages/app/src/js/store/store.ts @@ -0,0 +1,70 @@ +import { + Middleware, Dispatch, Action, +} from 'redux'; +import { configureStore } from '@reduxjs/toolkit' +import { client } from '../core'; +import * as slices from './slices'; +import { ActionCreator, AsyncAction, AsyncHandler, PayloadAction, Reducer } from './types'; +console.log(slices); + + +const isAsyncAction = (action: unknown): action is AsyncAction => (action as Action)?.type === 'async'; + +const middleware: Middleware = ({ dispatch, getState }) => (next) => async (action) => { + // FIXME: remove this after migrating + if (typeof action == 'function') { + (dispatch as any).actions = actions; + (dispatch as any).methods = methods; + try { + return await action(dispatch, getState, {client}); + } catch (err) { + // eslint-disable-next-line no-console + console.error(err); + return; + } + } + if (isAsyncAction(action)) { + const { handler } = action; + (dispatch as any).actions = actions; + (dispatch as any).methods = methods; + try { + return await handler(dispatch, getState, {client}); + } catch (err) { + // eslint-disable-next-line no-console + console.error(err); + return; + } + } + + return next(action); +}; + +export const store = configureStore({ + reducer: slices.reducers, + middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(middleware), +}); + +function remap(input: T): {[module: string]: {[key: string]: (data: unknown) => void}} { + return Object.keys(input) + .reduce void}}>>((acc, module: string) => { + const mod = input[module]; + acc[module] = Object.keys(mod) + .reduce<{[key: string]: (data: unknown) => void}>((acc2, action: string) => { + acc2[action] = async (data) => await store.dispatch(mod[action](data)).unwrap(); + return acc2; + }, {}); + return acc; + }, {}) as {[module: string]: {[key: string]: (data: unknown) => void}}; +} + +export const actions = remap(slices.actions); +export const methods = remap(slices.methods); +console.log(actions, methods); + +export type StateType = typeof slices.reducer extends Reducer ? S : never; +export type DispatchType = Dispatch + & { methods: typeof methods, actions: typeof actions }; +export type StoreType = typeof store & { dispatch: DispatchType }; + + +export const createAsyncAction = (handler: AsyncHandler) => ({ type: 'async', handler }); diff --git a/packages/app/src/js/store/tools.js b/packages/app/src/js/store/tools.js deleted file mode 100644 index 5d1a7463..00000000 --- a/packages/app/src/js/store/tools.js +++ /dev/null @@ -1,16 +0,0 @@ - -export const createModule = (def) => { - def.reducers = { ...def.reducers, reset: () => def.initialState }; - return ({ - actions: Object.keys(def.reducers).reduce((acc, key) => { - acc[key] = (data) => ({ type: `${def.name}/${key}`, payload: data }); - return acc; - }, {}), - methods: def.methods || {}, - reducer: (state = def.initialState, action) => { - if (!action.type.startsWith(def.name)) return state; - const reducer = def.reducers[action.type.split('/').slice(-1)[0]]; - return reducer ? reducer(state, action) : state; - }, - }); -} diff --git a/packages/app/src/js/store/tools.ts b/packages/app/src/js/store/tools.ts new file mode 100644 index 00000000..1559db31 --- /dev/null +++ b/packages/app/src/js/store/tools.ts @@ -0,0 +1,15 @@ +import { createAsyncThunk } from '@reduxjs/toolkit'; + +type MethodsDef = { + module_name: string; + methods: { + [key: string]: (args: any, { dispatch, getState }: { dispatch: D, getState: () => S }, api: API) => Promise; + } +} + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const createMethods = >(def: T) => { + return Object.fromEntries(Object.entries(def.methods).map(([key, val]) => { + return [key, createAsyncThunk(`${def.module_name}/${key}`, (arg: any, thunkApi: any) => val(arg, thunkApi, {}))]; + })); +} diff --git a/packages/app/src/js/store/types.ts b/packages/app/src/js/store/types.ts new file mode 100644 index 00000000..61b1a59f --- /dev/null +++ b/packages/app/src/js/store/types.ts @@ -0,0 +1,38 @@ +import { Action, ActionCreator as ActionCreatorPrev, Reducer } from 'redux'; + +export type { Action, Reducer }; + +export type AsyncHandler< S = unknown, D = unknown, API = unknown, R = unknown> = (dispatch: D, getState: () => S, api: API) => Promise; +export type AsyncAction = {type: 'async', handler: AsyncHandler}; +export type PayloadAction = { type: string, payload?: T }; + +export type ActionCreator = (args?: P) => PayloadAction; + +export type Method< S = unknown, D = unknown, API = unknown> = (...args: any) => (dispatch: D, getState: () => S, api: API) => Promise; + +// eslint-disable-next-line @typescript-eslint/ban-types +export type Module; } = {}> = { + reducer: Reducer; + actions: Actions, + methods: M; +}; + +export type Reducers = { + [key: string]: (state: T, action: PayloadAction) => T +}; +export type Methods = { [key: string]: (...args: any) => (dispatch: any, getState: () => any, api: any) => Promise }; + + +export type Actions = { [K in keyof T]: ActionCreator }; + +export type StateFromReducer = T extends Reducer ? S : never; + +export type CombinedState> = { [K in keyof T]: (T[K]['reducer'] extends Reducer ? S : never)}; + +export type CombinedModule> = { + actions: { [K in keyof T]: T[K]['actions'] }; + methods: { [K in keyof T]: T[K]['methods'] }; + reducer: Reducer, PayloadAction>; +}; + + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dfe13c7a..cfe80fb7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,6 +72,9 @@ importers: '@quack/rpc': specifier: workspace:* version: link:../rpc + '@reduxjs/toolkit': + specifier: 2.2.3 + version: 2.2.3(react-redux@9.1.0)(react@18.2.0) fuse.js: specifier: 7.0.0 version: 7.0.0 @@ -85,8 +88,8 @@ importers: specifier: 18.2.0 version: 18.2.0(react@18.2.0) react-redux: - specifier: 9.0.4 - version: 9.0.4(@types/react@18.2.48)(react@18.2.0)(redux@5.0.0) + specifier: 9.1.0 + version: 9.1.0(@types/react@18.2.48)(react@18.2.0)(redux@5.0.0) redux: specifier: 5.0.0 version: 5.0.0 @@ -2608,6 +2611,25 @@ packages: resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} dev: false + /@reduxjs/toolkit@2.2.3(react-redux@9.1.0)(react@18.2.0): + resolution: {integrity: sha512-76dll9EnJXg4EVcI5YNxZA/9hSAmZsFqzMmNRHvIlzw2WS/twfcVX3ysYrWGJMClwEmChQFC4yRq74tn6fdzRA==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + dependencies: + immer: 10.0.4 + react: 18.2.0 + react-redux: 9.1.0(@types/react@18.2.48)(react@18.2.0)(redux@5.0.0) + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.0 + dev: false + /@rollup/plugin-babel@5.3.1(@babel/core@7.23.6)(rollup@2.79.1): resolution: {integrity: sha512-WFfdLWU/xVWKeRQnKmIAQULUI7Il0gZnBIH/ZFO069wYIfPu+8zrfp/KMW0atmELoRDq8FbiP3VCss9MhCut7Q==} engines: {node: '>= 10.0.0'} @@ -6062,6 +6084,10 @@ packages: engines: {node: '>= 4'} dev: true + /immer@10.0.4: + resolution: {integrity: sha512-cuBuGK40P/sk5IzWa9QPUaAdvPHjkk1c+xYsd9oZw+YQQEV+10G0P5uMpGctZZKnyQ+ibRO08bD25nWLmYi2pw==} + dev: false + /import-fresh@3.3.0: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} engines: {node: '>=6'} @@ -7775,8 +7801,8 @@ packages: /react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} - /react-redux@9.0.4(@types/react@18.2.48)(react@18.2.0)(redux@5.0.0): - resolution: {integrity: sha512-9J1xh8sWO0vYq2sCxK2My/QO7MzUMRi3rpiILP/+tDr8krBHixC6JMM17fMK88+Oh3e4Ae6/sHIhNBgkUivwFA==} + /react-redux@9.1.0(@types/react@18.2.48)(react@18.2.0)(redux@5.0.0): + resolution: {integrity: sha512-6qoDzIO+gbrza8h3hjMA9aq4nwVFCKFtY2iLxCtVT38Swyy2C/dJCGBXHeHLtx6qlg/8qzc2MrhOeduf5K32wQ==} peerDependencies: '@types/react': ^18.2.25 react: ^18.0 @@ -7860,10 +7886,22 @@ packages: dependencies: resolve: 1.22.8 + /redux-thunk@3.1.0(redux@5.0.1): + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + dependencies: + redux: 5.0.1 + dev: false + /redux@5.0.0: resolution: {integrity: sha512-blLIYmYetpZMET6Q6uCY7Jtl/Im5OBldy+vNPauA8vvsdqyt66oep4EUpAMWNHauTC6xa9JuRPhRB72rY82QGA==} dev: false + /redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + dev: false + /reflect.getprototypeof@1.0.4: resolution: {integrity: sha512-ECkTw8TmJwW60lOTR+ZkODISW6RQ8+2CL3COqtiJKLd6MmB45hN51HprHFziKLGkAuTGQhBb91V8cy+KHlaCjw==} engines: {node: '>= 0.4'} @@ -7953,6 +7991,10 @@ packages: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} dev: false + /reselect@5.1.0: + resolution: {integrity: sha512-aw7jcGLDpSgNDyWBQLv2cedml85qd95/iszJjN988zX1t7AVRJi19d9kto5+W7oCfQ94gyo40dVbT6g2k4/kXg==} + dev: false + /resolve-cwd@3.0.0: resolution: {integrity: sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==} engines: {node: '>=8'}