refactor: lots updates

This commit is contained in:
zerosoul
2022-03-17 11:15:13 +08:00
parent 4bc9932d0f
commit f22c7a01cb
69 changed files with 4625 additions and 1640 deletions
+481 -459
View File
@@ -1,46 +1,48 @@
'use strict';
"use strict";
const fs = require('fs');
const path = require('path');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin');
const InlineChunkHtmlPlugin = require('react-dev-utils/InlineChunkHtmlPlugin');
const TerserPlugin = require('terser-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin');
const WorkboxWebpackPlugin = require('workbox-webpack-plugin');
const ModuleScopePlugin = require('react-dev-utils/ModuleScopePlugin');
const paths = require('./paths');
const modules = require('./modules');
const getClientEnvironment = require('./env');
const ModuleNotFoundPlugin = require('react-dev-utils/ModuleNotFoundPlugin');
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
const fs = require("fs");
const path = require("path");
const webpack = require("webpack");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const CaseSensitivePathsPlugin = require("case-sensitive-paths-webpack-plugin");
const InlineChunkHtmlPlugin = require("react-dev-utils/InlineChunkHtmlPlugin");
const TerserPlugin = require("terser-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
const { WebpackManifestPlugin } = require("webpack-manifest-plugin");
const InterpolateHtmlPlugin = require("react-dev-utils/InterpolateHtmlPlugin");
const WorkboxWebpackPlugin = require("workbox-webpack-plugin");
const ModuleScopePlugin = require("react-dev-utils/ModuleScopePlugin");
const paths = require("./paths");
const modules = require("./modules");
const getClientEnvironment = require("./env");
const ModuleNotFoundPlugin = require("react-dev-utils/ModuleNotFoundPlugin");
const ReactRefreshWebpackPlugin = require("@pmmmwh/react-refresh-webpack-plugin");
const createEnvironmentHash = require('./webpack/persistentCache/createEnvironmentHash');
const createEnvironmentHash = require("./webpack/persistentCache/createEnvironmentHash");
// Source maps are resource heavy and can cause out of memory issue for large source files.
const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== 'false';
const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== "false";
const reactRefreshRuntimeEntry = require.resolve('react-refresh/runtime');
const reactRefreshRuntimeEntry = require.resolve("react-refresh/runtime");
const reactRefreshWebpackPluginRuntimeEntry = require.resolve(
'@pmmmwh/react-refresh-webpack-plugin'
"@pmmmwh/react-refresh-webpack-plugin"
);
const babelRuntimeEntry = require.resolve('babel-preset-react-app');
const babelRuntimeEntry = require.resolve("babel-preset-react-app");
const babelRuntimeEntryHelpers = require.resolve(
'@babel/runtime/helpers/esm/assertThisInitialized',
{ paths: [babelRuntimeEntry] }
"@babel/runtime/helpers/esm/assertThisInitialized",
{ paths: [babelRuntimeEntry] }
);
const babelRuntimeRegenerator = require.resolve('@babel/runtime/regenerator', {
paths: [babelRuntimeEntry]
const babelRuntimeRegenerator = require.resolve("@babel/runtime/regenerator", {
paths: [babelRuntimeEntry],
});
// Some apps do not need the benefits of saving a web request, so not inlining the chunk
// makes for a smoother build process.
const shouldInlineRuntimeChunk = process.env.INLINE_RUNTIME_CHUNK !== 'false';
const shouldInlineRuntimeChunk = process.env.INLINE_RUNTIME_CHUNK !== "false";
const imageInlineSizeLimit = parseInt(process.env.IMAGE_INLINE_SIZE_LIMIT || '10000');
const imageInlineSizeLimit = parseInt(
process.env.IMAGE_INLINE_SIZE_LIMIT || "10000"
);
// Check if TypeScript is setup
const useTypeScript = fs.existsSync(paths.appTsConfig);
@@ -53,452 +55,472 @@ const cssRegex = /\.css$/;
const cssModuleRegex = /\.module\.css$/;
const hasJsxRuntime = (() => {
if (process.env.DISABLE_NEW_JSX_TRANSFORM === 'true') {
return false;
}
if (process.env.DISABLE_NEW_JSX_TRANSFORM === "true") {
return false;
}
try {
require.resolve('react/jsx-runtime');
return true;
} catch (e) {
return false;
}
try {
require.resolve("react/jsx-runtime");
return true;
} catch (e) {
return false;
}
})();
// This is the production and development configuration.
// It is focused on developer experience, fast rebuilds, and a minimal bundle.
module.exports = function (webpackEnv) {
const isEnvDevelopment = webpackEnv === 'development';
const isEnvProduction = webpackEnv === 'production';
const isEnvDevelopment = webpackEnv === "development";
const isEnvProduction = webpackEnv === "production";
// Variable used for enabling profiling in Production
// passed into alias object. Uses a flag if passed into the build command
const isEnvProductionProfile = isEnvProduction && process.argv.includes('--profile');
// Variable used for enabling profiling in Production
// passed into alias object. Uses a flag if passed into the build command
const isEnvProductionProfile =
isEnvProduction && process.argv.includes("--profile");
// We will provide `paths.publicUrlOrPath` to our app
// as %PUBLIC_URL% in `index.html` and `process.env.PUBLIC_URL` in JavaScript.
// Omit trailing slash as %PUBLIC_URL%/xyz looks better than %PUBLIC_URL%xyz.
// Get environment variables to inject into our app.
const env = getClientEnvironment(paths.publicUrlOrPath.slice(0, -1));
// We will provide `paths.publicUrlOrPath` to our app
// as %PUBLIC_URL% in `index.html` and `process.env.PUBLIC_URL` in JavaScript.
// Omit trailing slash as %PUBLIC_URL%/xyz looks better than %PUBLIC_URL%xyz.
// Get environment variables to inject into our app.
const env = getClientEnvironment(paths.publicUrlOrPath.slice(0, -1));
const shouldUseReactRefresh = env.raw.FAST_REFRESH;
const shouldUseReactRefresh = env.raw.FAST_REFRESH;
// common function to get style loaders
const getStyleLoaders = (cssOptions) => {
const loaders = [
isEnvDevelopment && require.resolve('style-loader'),
isEnvProduction && {
loader: MiniCssExtractPlugin.loader,
// css is located in `static/css`, use '../../' to locate index.html folder
// in production `paths.publicUrlOrPath` can be a relative path
options: paths.publicUrlOrPath.startsWith('.') ? { publicPath: '../../' } : {}
},
{
loader: require.resolve('css-loader'),
options: cssOptions
}
].filter(Boolean);
return loaders;
};
return {
target: ['browserslist'],
mode: isEnvProduction ? 'production' : isEnvDevelopment && 'development',
// Stop compilation early in production
bail: isEnvProduction,
devtool: isEnvProduction
? shouldUseSourceMap
? 'source-map'
: false
: isEnvDevelopment && 'cheap-module-source-map',
// These are the "entry points" to our application.
// This means they will be the "root" imports that are included in JS bundle.
entry: paths.appIndexJs,
output: {
// The build folder.
path: paths.appBuild,
// Add /* filename */ comments to generated require()s in the output.
pathinfo: isEnvDevelopment,
// There will be one main bundle, and one file per asynchronous chunk.
// In development, it does not produce real files.
filename: isEnvProduction
? 'static/js/[name].[contenthash:8].js'
: isEnvDevelopment && 'static/js/bundle.js',
// There are also additional JS chunk files if you use code splitting.
chunkFilename: isEnvProduction
? 'static/js/[name].[contenthash:8].chunk.js'
: isEnvDevelopment && 'static/js/[name].chunk.js',
assetModuleFilename: 'static/media/[name].[hash][ext]',
// webpack uses `publicPath` to determine where the app is being served from.
// It requires a trailing slash, or the file assets will get an incorrect path.
// We inferred the "public path" (such as / or /my-project) from homepage.
publicPath: paths.publicUrlOrPath,
// Point sourcemap entries to original disk location (format as URL on Windows)
devtoolModuleFilenameTemplate: isEnvProduction
? (info) => path.relative(paths.appSrc, info.absoluteResourcePath).replace(/\\/g, '/')
: isEnvDevelopment && ((info) => path.resolve(info.absoluteResourcePath).replace(/\\/g, '/'))
},
cache: {
type: 'filesystem',
version: createEnvironmentHash(env.raw),
cacheDirectory: paths.appWebpackCache,
store: 'pack',
buildDependencies: {
defaultWebpack: ['webpack/lib/'],
config: [__filename],
tsconfig: [paths.appTsConfig, paths.appJsConfig].filter((f) => fs.existsSync(f))
}
},
infrastructureLogging: {
level: 'none'
},
optimization: {
minimize: isEnvProduction,
minimizer: [
// This is only used in production mode
new TerserPlugin({
terserOptions: {
parse: {
// We want terser to parse ecma 8 code. However, we don't want it
// to apply any minification steps that turns valid ecma 5 code
// into invalid ecma 5 code. This is why the 'compress' and 'output'
// sections only apply transformations that are ecma 5 safe
// https://github.com/facebook/create-react-app/pull/4234
ecma: 8
// common function to get style loaders
const getStyleLoaders = (cssOptions) => {
const loaders = [
isEnvDevelopment && require.resolve("style-loader"),
isEnvProduction && {
loader: MiniCssExtractPlugin.loader,
// css is located in `static/css`, use '../../' to locate index.html folder
// in production `paths.publicUrlOrPath` can be a relative path
options: paths.publicUrlOrPath.startsWith(".")
? { publicPath: "../../" }
: {},
},
compress: {
ecma: 5,
warnings: false,
// Disabled because of an issue with Uglify breaking seemingly valid code:
// https://github.com/facebook/create-react-app/issues/2376
// Pending further investigation:
// https://github.com/mishoo/UglifyJS2/issues/2011
comparisons: false,
// Disabled because of an issue with Terser breaking valid code:
// https://github.com/facebook/create-react-app/issues/5250
// Pending further investigation:
// https://github.com/terser-js/terser/issues/120
inline: 2
{
loader: require.resolve("css-loader"),
options: cssOptions,
},
mangle: {
safari10: true
},
// Added for profiling in devtools
keep_classnames: isEnvProductionProfile,
keep_fnames: isEnvProductionProfile,
output: {
ecma: 5,
comments: false,
// Turned on because emoji and regex is not minified properly using default
// https://github.com/facebook/create-react-app/issues/2488
ascii_only: true
}
}
}),
// This is only used in production mode
new CssMinimizerPlugin()
]
},
resolve: {
// This allows you to set a fallback for where webpack should look for modules.
// We placed these paths second because we want `node_modules` to "win"
// if there are any conflicts. This matches Node resolution mechanism.
// https://github.com/facebook/create-react-app/issues/253
modules: ['node_modules', paths.appNodeModules].concat(modules.additionalModulePaths || []),
// These are the reasonable defaults supported by the Node ecosystem.
// We also include JSX as a common component filename extension to support
// some tools, although we do not recommend using it, see:
// https://github.com/facebook/create-react-app/issues/290
// `web` extension prefixes have been added for better support
// for React Native Web.
extensions: paths.moduleFileExtensions
.map((ext) => `.${ext}`)
.filter((ext) => useTypeScript || !ext.includes('ts')),
alias: {
// Allows for better profiling with ReactDevTools
...(isEnvProductionProfile && {
'react-dom$': 'react-dom/profiling',
'scheduler/tracing': 'scheduler/tracing-profiling'
}),
...(modules.webpackAliases || {})
},
plugins: [
// Prevents users from importing files from outside of src/ (or node_modules/).
// This often causes confusion because we only process files within src/ with babel.
// To fix this, we prevent you from importing files out of src/ -- if you'd like to,
// please link the files into your node_modules/ and let module-resolution kick in.
// Make sure your source files are compiled, as they will not be processed in any way.
new ModuleScopePlugin(paths.appSrc, [
paths.appPackageJson,
reactRefreshRuntimeEntry,
reactRefreshWebpackPluginRuntimeEntry,
babelRuntimeEntry,
babelRuntimeEntryHelpers,
babelRuntimeRegenerator
])
]
},
module: {
strictExportPresence: true,
rules: [
// Handle node_modules packages that contain sourcemaps
shouldUseSourceMap && {
enforce: 'pre',
exclude: /@babel(?:\/|\\{1,2})runtime/,
test: /\.(js|mjs|jsx|ts|tsx|css)$/,
loader: require.resolve('source-map-loader')
].filter(Boolean);
return loaders;
};
return {
target: ["browserslist"],
mode: isEnvProduction ? "production" : isEnvDevelopment && "development",
// Stop compilation early in production
bail: isEnvProduction,
devtool: isEnvProduction
? shouldUseSourceMap
? "source-map"
: false
: isEnvDevelopment && "cheap-module-source-map",
// These are the "entry points" to our application.
// This means they will be the "root" imports that are included in JS bundle.
entry: paths.appIndexJs,
output: {
// The build folder.
path: paths.appBuild,
// Add /* filename */ comments to generated require()s in the output.
pathinfo: isEnvDevelopment,
// There will be one main bundle, and one file per asynchronous chunk.
// In development, it does not produce real files.
filename: isEnvProduction
? "static/js/[name].[contenthash:8].js"
: isEnvDevelopment && "static/js/bundle.js",
// There are also additional JS chunk files if you use code splitting.
chunkFilename: isEnvProduction
? "static/js/[name].[contenthash:8].chunk.js"
: isEnvDevelopment && "static/js/[name].chunk.js",
assetModuleFilename: "static/media/[name].[hash][ext]",
// webpack uses `publicPath` to determine where the app is being served from.
// It requires a trailing slash, or the file assets will get an incorrect path.
// We inferred the "public path" (such as / or /my-project) from homepage.
publicPath: paths.publicUrlOrPath,
// Point sourcemap entries to original disk location (format as URL on Windows)
devtoolModuleFilenameTemplate: isEnvProduction
? (info) =>
path
.relative(paths.appSrc, info.absoluteResourcePath)
.replace(/\\/g, "/")
: isEnvDevelopment &&
((info) =>
path.resolve(info.absoluteResourcePath).replace(/\\/g, "/")),
},
{
// "oneOf" will traverse all following loaders until one will
// match the requirements. When no loader matches it will fall
// back to the "file" loader at the end of the loader list.
oneOf: [
// TODO: Merge this config once `image/avif` is in the mime-db
// https://github.com/jshttp/mime-db
{
test: [/\.avif$/],
type: 'asset',
mimetype: 'image/avif',
parser: {
dataUrlCondition: {
maxSize: imageInlineSizeLimit
}
}
cache: {
type: "filesystem",
version: createEnvironmentHash(env.raw),
cacheDirectory: paths.appWebpackCache,
store: "pack",
buildDependencies: {
defaultWebpack: ["webpack/lib/"],
config: [__filename],
tsconfig: [paths.appTsConfig, paths.appJsConfig].filter((f) =>
fs.existsSync(f)
),
},
// "url" loader works like "file" loader except that it embeds assets
// smaller than specified limit in bytes as data URLs to avoid requests.
// A missing `test` is equivalent to a match.
{
test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
type: 'asset',
parser: {
dataUrlCondition: {
maxSize: imageInlineSizeLimit
}
}
},
{
test: /\.svg$/,
use: [
{
loader: require.resolve('@svgr/webpack'),
options: {
prettier: false,
svgo: false,
svgoConfig: {
plugins: [{ removeViewBox: false }]
},
infrastructureLogging: {
level: "none",
},
optimization: {
minimize: isEnvProduction,
minimizer: [
// This is only used in production mode
new TerserPlugin({
terserOptions: {
parse: {
// We want terser to parse ecma 8 code. However, we don't want it
// to apply any minification steps that turns valid ecma 5 code
// into invalid ecma 5 code. This is why the 'compress' and 'output'
// sections only apply transformations that are ecma 5 safe
// https://github.com/facebook/create-react-app/pull/4234
ecma: 8,
},
compress: {
ecma: 5,
warnings: false,
// Disabled because of an issue with Uglify breaking seemingly valid code:
// https://github.com/facebook/create-react-app/issues/2376
// Pending further investigation:
// https://github.com/mishoo/UglifyJS2/issues/2011
comparisons: false,
// Disabled because of an issue with Terser breaking valid code:
// https://github.com/facebook/create-react-app/issues/5250
// Pending further investigation:
// https://github.com/terser-js/terser/issues/120
inline: 2,
},
mangle: {
safari10: true,
},
// Added for profiling in devtools
keep_classnames: isEnvProductionProfile,
keep_fnames: isEnvProductionProfile,
output: {
ecma: 5,
comments: false,
// Turned on because emoji and regex is not minified properly using default
// https://github.com/facebook/create-react-app/issues/2488
ascii_only: true,
},
},
titleProp: true,
ref: true
}
}),
// This is only used in production mode
new CssMinimizerPlugin(),
],
},
resolve: {
// This allows you to set a fallback for where webpack should look for modules.
// We placed these paths second because we want `node_modules` to "win"
// if there are any conflicts. This matches Node resolution mechanism.
// https://github.com/facebook/create-react-app/issues/253
modules: ["node_modules", paths.appNodeModules].concat(
modules.additionalModulePaths || []
),
// These are the reasonable defaults supported by the Node ecosystem.
// We also include JSX as a common component filename extension to support
// some tools, although we do not recommend using it, see:
// https://github.com/facebook/create-react-app/issues/290
// `web` extension prefixes have been added for better support
// for React Native Web.
extensions: paths.moduleFileExtensions
.map((ext) => `.${ext}`)
.filter((ext) => useTypeScript || !ext.includes("ts")),
alias: {
// Allows for better profiling with ReactDevTools
...(isEnvProductionProfile && {
"react-dom$": "react-dom/profiling",
"scheduler/tracing": "scheduler/tracing-profiling",
}),
...(modules.webpackAliases || {}),
},
plugins: [
// Prevents users from importing files from outside of src/ (or node_modules/).
// This often causes confusion because we only process files within src/ with babel.
// To fix this, we prevent you from importing files out of src/ -- if you'd like to,
// please link the files into your node_modules/ and let module-resolution kick in.
// Make sure your source files are compiled, as they will not be processed in any way.
new ModuleScopePlugin(paths.appSrc, [
paths.appPackageJson,
reactRefreshRuntimeEntry,
reactRefreshWebpackPluginRuntimeEntry,
babelRuntimeEntry,
babelRuntimeEntryHelpers,
babelRuntimeRegenerator,
]),
],
},
module: {
strictExportPresence: true,
rules: [
// Handle node_modules packages that contain sourcemaps
shouldUseSourceMap && {
enforce: "pre",
exclude: /@babel(?:\/|\\{1,2})runtime/,
test: /\.(js|mjs|jsx|ts|tsx|css)$/,
loader: require.resolve("source-map-loader"),
},
{
loader: require.resolve('file-loader'),
options: {
name: 'static/media/[name].[hash].[ext]'
}
}
],
issuer: {
and: [/\.(ts|tsx|js|jsx|md|mdx)$/]
}
},
// Process application JS with Babel.
// The preset includes JSX, Flow, TypeScript, and some ESnext features.
{
test: /\.(js|mjs|jsx|ts|tsx)$/,
include: paths.appSrc,
loader: require.resolve('babel-loader'),
options: {
customize: require.resolve('babel-preset-react-app/webpack-overrides'),
presets: [
[
require.resolve('babel-preset-react-app'),
// "oneOf" will traverse all following loaders until one will
// match the requirements. When no loader matches it will fall
// back to the "file" loader at the end of the loader list.
oneOf: [
// TODO: Merge this config once `image/avif` is in the mime-db
// https://github.com/jshttp/mime-db
{
test: [/\.avif$/],
type: "asset",
mimetype: "image/avif",
parser: {
dataUrlCondition: {
maxSize: imageInlineSizeLimit,
},
},
},
// "url" loader works like "file" loader except that it embeds assets
// smaller than specified limit in bytes as data URLs to avoid requests.
// A missing `test` is equivalent to a match.
{
test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
type: "asset",
parser: {
dataUrlCondition: {
maxSize: imageInlineSizeLimit,
},
},
},
{
test: /\.svg$/,
use: [
{
loader: require.resolve("@svgr/webpack"),
options: {
prettier: false,
svgo: false,
svgoConfig: {
plugins: [{ removeViewBox: false }],
},
titleProp: true,
ref: true,
},
},
],
issuer: {
and: [/\.(ts|tsx|js|jsx|md|mdx)$/],
},
resourceQuery: { not: [/url/] }, // exclude react component if *.svg?url
},
// Process application JS with Babel.
// The preset includes JSX, Flow, TypeScript, and some ESnext features.
{
test: /\.(js|mjs|jsx|ts|tsx)$/,
include: paths.appSrc,
loader: require.resolve("babel-loader"),
options: {
customize: require.resolve(
"babel-preset-react-app/webpack-overrides"
),
presets: [
[
require.resolve("babel-preset-react-app"),
{
runtime: hasJsxRuntime ? "automatic" : "classic",
},
],
],
plugins: [
isEnvDevelopment &&
shouldUseReactRefresh &&
require.resolve("react-refresh/babel"),
].filter(Boolean),
// This is a feature of `babel-loader` for webpack (not Babel itself).
// It enables caching results in ./node_modules/.cache/babel-loader/
// directory for faster rebuilds.
cacheDirectory: true,
// See #6846 for context on why cacheCompression is disabled
cacheCompression: false,
compact: isEnvProduction,
},
},
// Process any JS outside of the app with Babel.
// Unlike the application JS, we only compile the standard ES features.
{
test: /\.(js|mjs)$/,
exclude: /@babel(?:\/|\\{1,2})runtime/,
loader: require.resolve("babel-loader"),
options: {
babelrc: false,
configFile: false,
compact: false,
presets: [
[
require.resolve("babel-preset-react-app/dependencies"),
{ helpers: true },
],
],
cacheDirectory: true,
// See #6846 for context on why cacheCompression is disabled
cacheCompression: false,
// Babel sourcemaps are needed for debugging into node_modules
// code. Without the options below, debuggers like VSCode
// show incorrect code and set breakpoints on the wrong lines.
sourceMaps: shouldUseSourceMap,
inputSourceMap: shouldUseSourceMap,
},
},
// "css" loader resolves paths in CSS and adds assets as dependencies.
// "style" loader turns CSS into JS modules that inject <style> tags.
// In production, we use MiniCSSExtractPlugin to extract that CSS
// to a file, but in development "style" loader enables hot editing
// of CSS.
// By default we support CSS Modules with the extension .module.css
{
test: cssRegex,
exclude: cssModuleRegex,
use: getStyleLoaders({
importLoaders: 1,
sourceMap: isEnvProduction
? shouldUseSourceMap
: isEnvDevelopment,
modules: {
mode: "icss",
},
}),
// Don't consider CSS imports dead code even if the
// containing package claims to have no side effects.
// Remove this when webpack adds a warning or an error for this.
// See https://github.com/webpack/webpack/issues/6571
sideEffects: true,
},
// "file" loader makes sure those assets get served by WebpackDevServer.
// When you `import` an asset, you get its (virtual) filename.
// In production, they would get copied to the `build` folder.
// This loader doesn't use a "test" so it will catch all modules
// that fall through the other loaders.
{
// Exclude `js` files to keep "css" loader working as it injects
// its runtime that would otherwise be processed through "file" loader.
// Also exclude `html` and `json` extensions so they get processed
// by webpacks internal loaders.
exclude: [/^$/, /\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/],
type: "asset/resource",
},
// ** STOP ** Are you adding a new loader?
// Make sure to add the new loader(s) before the "file" loader.
],
},
].filter(Boolean),
},
plugins: [
// Generates an `index.html` file with the <script> injected.
new HtmlWebpackPlugin(
Object.assign(
{},
{
runtime: hasJsxRuntime ? 'automatic' : 'classic'
}
]
],
inject: true,
template: paths.appHtml,
},
isEnvProduction
? {
minify: {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: true,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyJS: true,
minifyCSS: true,
minifyURLs: true,
},
}
: undefined
)
),
// Inlines the webpack runtime script. This script is too small to warrant
// a network request.
// https://github.com/facebook/create-react-app/issues/5358
isEnvProduction &&
shouldInlineRuntimeChunk &&
new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/runtime-.+[.]js/]),
// Makes some environment variables available in index.html.
// The public URL is available as %PUBLIC_URL% in index.html, e.g.:
// <link rel="icon" href="%PUBLIC_URL%/favicon.ico">
// It will be an empty string unless you specify "homepage"
// in `package.json`, in which case it will be the pathname of that URL.
new InterpolateHtmlPlugin(HtmlWebpackPlugin, env.raw),
// This gives some necessary context to module not found errors, such as
// the requesting resource.
new ModuleNotFoundPlugin(paths.appPath),
// Makes some environment variables available to the JS code, for example:
// if (process.env.NODE_ENV === 'production') { ... }. See `./env.js`.
// It is absolutely essential that NODE_ENV is set to production
// during a production build.
// Otherwise React will be compiled in the very slow development mode.
new webpack.DefinePlugin(env.stringified),
// Experimental hot reloading for React .
// https://github.com/facebook/react/tree/main/packages/react-refresh
isEnvDevelopment &&
shouldUseReactRefresh &&
new ReactRefreshWebpackPlugin({
overlay: false,
}),
// Watcher doesn't work well if you mistype casing in a path so we use
// a plugin that prints an error when you attempt to do this.
// See https://github.com/facebook/create-react-app/issues/240
isEnvDevelopment && new CaseSensitivePathsPlugin(),
isEnvProduction &&
new MiniCssExtractPlugin({
// Options similar to the same options in webpackOptions.output
// both options are optional
filename: "static/css/[name].[contenthash:8].css",
chunkFilename: "static/css/[name].[contenthash:8].chunk.css",
}),
// Generate an asset manifest file with the following content:
// - "files" key: Mapping of all asset filenames to their corresponding
// output file so that tools can pick it up without having to parse
// `index.html`
// - "entrypoints" key: Array of files which are included in `index.html`,
// can be used to reconstruct the HTML if necessary
new WebpackManifestPlugin({
fileName: "asset-manifest.json",
publicPath: paths.publicUrlOrPath,
generate: (seed, files, entrypoints) => {
const manifestFiles = files.reduce((manifest, file) => {
manifest[file.name] = file.path;
return manifest;
}, seed);
const entrypointFiles = entrypoints.main.filter(
(fileName) => !fileName.endsWith(".map")
);
plugins: [
isEnvDevelopment && shouldUseReactRefresh && require.resolve('react-refresh/babel')
].filter(Boolean),
// This is a feature of `babel-loader` for webpack (not Babel itself).
// It enables caching results in ./node_modules/.cache/babel-loader/
// directory for faster rebuilds.
cacheDirectory: true,
// See #6846 for context on why cacheCompression is disabled
cacheCompression: false,
compact: isEnvProduction
}
},
// Process any JS outside of the app with Babel.
// Unlike the application JS, we only compile the standard ES features.
{
test: /\.(js|mjs)$/,
exclude: /@babel(?:\/|\\{1,2})runtime/,
loader: require.resolve('babel-loader'),
options: {
babelrc: false,
configFile: false,
compact: false,
presets: [[require.resolve('babel-preset-react-app/dependencies'), { helpers: true }]],
cacheDirectory: true,
// See #6846 for context on why cacheCompression is disabled
cacheCompression: false,
// Babel sourcemaps are needed for debugging into node_modules
// code. Without the options below, debuggers like VSCode
// show incorrect code and set breakpoints on the wrong lines.
sourceMaps: shouldUseSourceMap,
inputSourceMap: shouldUseSourceMap
}
},
// "css" loader resolves paths in CSS and adds assets as dependencies.
// "style" loader turns CSS into JS modules that inject <style> tags.
// In production, we use MiniCSSExtractPlugin to extract that CSS
// to a file, but in development "style" loader enables hot editing
// of CSS.
// By default we support CSS Modules with the extension .module.css
{
test: cssRegex,
exclude: cssModuleRegex,
use: getStyleLoaders({
importLoaders: 1,
sourceMap: isEnvProduction ? shouldUseSourceMap : isEnvDevelopment,
modules: {
mode: 'icss'
}
}),
// Don't consider CSS imports dead code even if the
// containing package claims to have no side effects.
// Remove this when webpack adds a warning or an error for this.
// See https://github.com/webpack/webpack/issues/6571
sideEffects: true
},
// "file" loader makes sure those assets get served by WebpackDevServer.
// When you `import` an asset, you get its (virtual) filename.
// In production, they would get copied to the `build` folder.
// This loader doesn't use a "test" so it will catch all modules
// that fall through the other loaders.
{
// Exclude `js` files to keep "css" loader working as it injects
// its runtime that would otherwise be processed through "file" loader.
// Also exclude `html` and `json` extensions so they get processed
// by webpacks internal loaders.
exclude: [/^$/, /\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/],
type: 'asset/resource'
}
// ** STOP ** Are you adding a new loader?
// Make sure to add the new loader(s) before the "file" loader.
]
}
].filter(Boolean)
},
plugins: [
// Generates an `index.html` file with the <script> injected.
new HtmlWebpackPlugin(
Object.assign(
{},
{
inject: true,
template: paths.appHtml
},
isEnvProduction
? {
minify: {
removeComments: true,
collapseWhitespace: true,
removeRedundantAttributes: true,
useShortDoctype: true,
removeEmptyAttributes: true,
removeStyleLinkTypeAttributes: true,
keepClosingSlash: true,
minifyJS: true,
minifyCSS: true,
minifyURLs: true
}
}
: undefined
)
),
// Inlines the webpack runtime script. This script is too small to warrant
// a network request.
// https://github.com/facebook/create-react-app/issues/5358
isEnvProduction &&
shouldInlineRuntimeChunk &&
new InlineChunkHtmlPlugin(HtmlWebpackPlugin, [/runtime-.+[.]js/]),
// Makes some environment variables available in index.html.
// The public URL is available as %PUBLIC_URL% in index.html, e.g.:
// <link rel="icon" href="%PUBLIC_URL%/favicon.ico">
// It will be an empty string unless you specify "homepage"
// in `package.json`, in which case it will be the pathname of that URL.
new InterpolateHtmlPlugin(HtmlWebpackPlugin, env.raw),
// This gives some necessary context to module not found errors, such as
// the requesting resource.
new ModuleNotFoundPlugin(paths.appPath),
// Makes some environment variables available to the JS code, for example:
// if (process.env.NODE_ENV === 'production') { ... }. See `./env.js`.
// It is absolutely essential that NODE_ENV is set to production
// during a production build.
// Otherwise React will be compiled in the very slow development mode.
new webpack.DefinePlugin(env.stringified),
// Experimental hot reloading for React .
// https://github.com/facebook/react/tree/main/packages/react-refresh
isEnvDevelopment &&
shouldUseReactRefresh &&
new ReactRefreshWebpackPlugin({
overlay: false
}),
// Watcher doesn't work well if you mistype casing in a path so we use
// a plugin that prints an error when you attempt to do this.
// See https://github.com/facebook/create-react-app/issues/240
isEnvDevelopment && new CaseSensitivePathsPlugin(),
isEnvProduction &&
new MiniCssExtractPlugin({
// Options similar to the same options in webpackOptions.output
// both options are optional
filename: 'static/css/[name].[contenthash:8].css',
chunkFilename: 'static/css/[name].[contenthash:8].chunk.css'
}),
// Generate an asset manifest file with the following content:
// - "files" key: Mapping of all asset filenames to their corresponding
// output file so that tools can pick it up without having to parse
// `index.html`
// - "entrypoints" key: Array of files which are included in `index.html`,
// can be used to reconstruct the HTML if necessary
new WebpackManifestPlugin({
fileName: 'asset-manifest.json',
publicPath: paths.publicUrlOrPath,
generate: (seed, files, entrypoints) => {
const manifestFiles = files.reduce((manifest, file) => {
manifest[file.name] = file.path;
return manifest;
}, seed);
const entrypointFiles = entrypoints.main.filter((fileName) => !fileName.endsWith('.map'));
return {
files: manifestFiles,
entrypoints: entrypointFiles
};
}
}),
// Generate a service worker script that will precache, and keep up to date,
// the HTML & assets that are part of the webpack build.
isEnvProduction &&
fs.existsSync(swSrc) &&
new WorkboxWebpackPlugin.InjectManifest({
swSrc,
dontCacheBustURLsMatching: /\.[0-9a-f]{8}\./,
exclude: [/\.map$/, /asset-manifest\.json$/, /LICENSE/],
// Bump up the default maximum size (2mb) that's precached,
// to make lazy-loading failure scenarios less likely.
// See https://github.com/cra-template/pwa/issues/13#issuecomment-722667270
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024
})
].filter(Boolean),
// Turn off performance processing because we utilize
// our own hints via the FileSizeReporter
performance: false
};
return {
files: manifestFiles,
entrypoints: entrypointFiles,
};
},
}),
// Generate a service worker script that will precache, and keep up to date,
// the HTML & assets that are part of the webpack build.
isEnvProduction &&
fs.existsSync(swSrc) &&
new WorkboxWebpackPlugin.InjectManifest({
swSrc,
dontCacheBustURLsMatching: /\.[0-9a-f]{8}\./,
exclude: [/\.map$/, /asset-manifest\.json$/, /LICENSE/],
// Bump up the default maximum size (2mb) that's precached,
// to make lazy-loading failure scenarios less likely.
// See https://github.com/cra-template/pwa/issues/13#issuecomment-722667270
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
}),
].filter(Boolean),
// Turn off performance processing because we utilize
// our own hints via the FileSizeReporter
performance: false,
};
};
+120 -108
View File
@@ -1,110 +1,122 @@
{
"name": "rustchat-web",
"version": "0.1.0",
"private": true,
"homepage": "http://privoce.rustchat.com",
"dependencies": {
"@babel/core": "^7.17.5",
"@metamask/onboarding": "^1.0.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.4",
"@reduxjs/toolkit": "^1.8.0",
"@rtk-query/codegen-openapi": "^1.0.0-alpha.1",
"@svgr/webpack": "^6.2.1",
"@tippyjs/react": "^4.2.6",
"animate.css": "^4.1.1",
"axios": "^0.26.1",
"babel-loader": "^8.2.3",
"babel-plugin-named-asset-import": "^0.3.8",
"babel-preset-react-app": "^10.0.1",
"bfj": "^7.0.2",
"browserslist": "^4.20.0",
"camelcase": "^6.3.0",
"case-sensitive-paths-webpack-plugin": "^2.4.0",
"css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^3.4.1",
"dayjs": "^1.10.8",
"dotenv": "^16.0.0",
"dotenv-expand": "^8.0.1",
"emoji-mart": "^3.0.1",
"eslint": "^8.10.0",
"event-source-polyfill": "^1.0.25",
"file-loader": "^6.2.0",
"firebase": "^9.6.8",
"fs-extra": "^10.0.1",
"html-webpack-plugin": "^5.5.0",
"linkify-it": "^3.0.3",
"localforage": "^1.10.0",
"mini-css-extract-plugin": "^2.6.0",
"react": "^17.0.2",
"react-dev-utils": "^12.0.0",
"react-dnd": "^15.1.1",
"react-dnd-html5-backend": "^15.1.2",
"react-dom": "^17.0.2",
"react-google-login": "^5.2.2",
"react-helmet": "^6.1.0",
"react-hot-toast": "^2.2.0",
"react-icons": "^4.3.1",
"react-linkify": "^1.0.0-alpha",
"react-redux": "^7.2.6",
"react-refresh": "^0.11.0",
"react-router-dom": "6",
"react-textarea-autosize": "^8.3.3",
"react-window": "^1.8.6",
"redux-persist": "^6.0.0",
"resolve": "^1.22.0",
"rooks": "^5.10.2",
"semver": "^7.3.5",
"source-map-loader": "^3.0.1",
"style-loader": "^3.3.1",
"styled-components": "^5.3.3",
"styled-reset": "^4.3.4",
"terser-webpack-plugin": "^5.3.1",
"tippy.js": "^6.3.7",
"webpack": "^5.70.0",
"webpack-dev-server": "^4.7.4",
"webpack-manifest-plugin": "^5.0.0",
"workbox-webpack-plugin": "^6.5.1"
},
"scripts": {
"start": "node scripts/start.js",
"build": "node scripts/build.js",
"deploy": "yarn build && gh-pages -d build",
"lint": "lint-staged",
"prepare": "husky install"
},
"browserslist": {
"production": [
"last 5 chrome version",
"last 5 firefox version",
"last 5 safari version"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"babel": {
"presets": [
"react-app"
]
},
"devDependencies": {
"@commitlint/cli": "^16.2.1",
"@commitlint/config-conventional": "^16.2.1",
"autoprefixer": "^10.4.2",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.29.3",
"eslint-plugin-react-hooks": "^4.3.0",
"gh-pages": "^3.2.3",
"husky": "^7.0.4",
"lint-staged": "^12.3.5",
"postcss": "^8.4.8",
"postcss-flexbugs-fixes": "^5.0.2",
"postcss-loader": "^6.2.1",
"postcss-preset-env": "^7.4.2",
"prettier": "^2.5.1"
}
"name": "rustchat-web",
"version": "0.1.0",
"private": true,
"homepage": "http://privoce.rustchat.com",
"dependencies": {
"@babel/core": "^7.17.5",
"@metamask/onboarding": "^1.0.1",
"@microsoft/fetch-event-source": "^2.0.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.4",
"@reduxjs/toolkit": "^1.8.0",
"@rtk-query/codegen-openapi": "^1.0.0-alpha.1",
"@svgr/webpack": "^6.2.1",
"@tippyjs/react": "^4.2.6",
"animate.css": "^4.1.1",
"axios": "^0.26.1",
"babel-loader": "^8.2.3",
"babel-plugin-named-asset-import": "^0.3.8",
"babel-preset-react-app": "^10.0.1",
"bfj": "^7.0.2",
"browserslist": "^4.20.0",
"camelcase": "^6.3.0",
"case-sensitive-paths-webpack-plugin": "^2.4.0",
"css-loader": "^6.7.1",
"css-minimizer-webpack-plugin": "^3.4.1",
"dayjs": "^1.10.8",
"dotenv": "^16.0.0",
"dotenv-expand": "^8.0.1",
"emoji-mart": "^3.0.1",
"eslint": "^8.10.0",
"event-source-polyfill": "^1.0.25",
"file-loader": "^6.2.0",
"firebase": "^9.6.8",
"fs-extra": "^10.0.1",
"github-markdown-css": "^5.1.0",
"html-webpack-plugin": "^5.5.0",
"linkify-it": "^3.0.3",
"localforage": "^1.10.0",
"mini-css-extract-plugin": "^2.6.0",
"react": "^17.0.2",
"react-dev-utils": "^12.0.0",
"react-dnd": "^15.1.1",
"react-dnd-html5-backend": "^15.1.2",
"react-dom": "^17.0.2",
"react-google-login": "^5.2.2",
"react-helmet": "^6.1.0",
"react-hot-toast": "^2.2.0",
"react-icons": "^4.3.1",
"react-linkify": "^1.0.0-alpha",
"react-markdown": "^8.0.1",
"react-mde": "^11.5.0",
"react-redux": "^7.2.6",
"react-refresh": "^0.11.0",
"react-router-dom": "6",
"react-textarea-autosize": "^8.3.3",
"react-window": "^1.8.6",
"remark-gfm": "^3.0.1",
"resolve": "^1.22.0",
"rich-markdown-editor": "^11.21.3",
"rooks": "^5.10.2",
"semver": "^7.3.5",
"showdown": "^2.0.3",
"slate": "^0.73.1",
"slate-history": "^0.66.0",
"slate-react": "^0.74.2",
"source-map-loader": "^3.0.1",
"style-loader": "^3.3.1",
"styled-components": "^5.3.3",
"styled-reset": "^4.3.4",
"terser-webpack-plugin": "^5.3.1",
"tippy.js": "^6.3.7",
"webpack": "^5.70.0",
"webpack-dev-server": "^4.7.4",
"webpack-manifest-plugin": "^5.0.0",
"workbox-webpack-plugin": "^6.5.1"
},
"resolutions": {
"prosemirror-tables": "^1.1.1"
},
"scripts": {
"start": "node scripts/start.js",
"build": "node scripts/build.js",
"deploy": "yarn build && gh-pages -d build",
"lint": "lint-staged",
"prepare": "husky install"
},
"browserslist": {
"production": [
"last 5 chrome version",
"last 5 firefox version",
"last 5 safari version"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"babel": {
"presets": [
"react-app"
]
},
"devDependencies": {
"@commitlint/cli": "^16.2.1",
"@commitlint/config-conventional": "^16.2.1",
"autoprefixer": "^10.4.2",
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.25.4",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-react": "^7.29.3",
"eslint-plugin-react-hooks": "^4.3.0",
"gh-pages": "^3.2.3",
"husky": "^7.0.4",
"lint-staged": "^12.3.5",
"postcss": "^8.4.8",
"postcss-flexbugs-fixes": "^5.0.2",
"postcss-loader": "^6.2.1",
"postcss-preset-env": "^7.4.2",
"prettier": "^2.5.1"
}
}
+11 -1
View File
@@ -2,6 +2,7 @@ import { useState } from "react";
import localforage from "localforage";
import { useDispatch, batch } from "react-redux";
import { fullfillReactionMessage } from "./slices/message.reaction";
import { fullfillServer } from "./slices/server";
import { fullfillMessage } from "./slices/message";
import { fullfillChannelMsg } from "./slices/message.channel";
import { fullfillUserMsg } from "./slices/message.user";
@@ -38,6 +39,10 @@ const tables = [
storeName: "footprint",
description: "store user visit data",
},
{
storeName: "server",
description: "store server data",
},
// {
// storeName: "message",
// description: "store message with key-val full data",
@@ -67,6 +72,7 @@ export const useRehydrate = () => {
reactionMessage: {},
message: { replying: {} },
footprint: {},
server: {},
};
const tables = Object.keys(window.CACHE);
const results = await Promise.all(
@@ -95,6 +101,9 @@ export const useRehydrate = () => {
case "message":
rehydrateData.message[key] = data;
break;
case "server":
rehydrateData.server[key] = data;
break;
default:
break;
@@ -104,6 +113,7 @@ export const useRehydrate = () => {
);
batch(() => {
dispatch(fullfillContacts(rehydrateData.contacts));
dispatch(fullfillServer(rehydrateData.server));
console.log("fullfill channels from indexedDB");
dispatch(fullfillChannels(rehydrateData.channels));
dispatch(fullfillChannelMsg(rehydrateData.channelMessage));
@@ -114,7 +124,7 @@ export const useRehydrate = () => {
});
setIterated(true);
// console.log("iterate results", rehydrateData, results);
console.log("iterate results", rehydrateData, results);
};
return { rehydrate, rehydrated: iterated };
};
+4
View File
@@ -5,6 +5,8 @@ export const ContentTypes = {
text: "text/plain",
markdown: "text/markdown",
image: "image/png",
imageJPG: "image/jpeg",
file: "rustchat/file",
json: "application/json",
};
export const googleClientID =
@@ -15,7 +17,9 @@ export const KEY_TOKEN = "RUSTCHAT_TOKEN";
export const KEY_EXPIRE = "RUSTCHAT_TOKEN_EXPIRE";
export const KEY_REFRESH_TOKEN = "RUSTCHAT_REFRESH_TOKEN";
export const KEY_UID = "RUSTCHAT_CURR_UID";
export const KEY_DEVICE_KEY = "RUSTCHAT_DEVICE_KEY";
export const KEY_USERS_VERSION = "RUSTCHAT_USERS_VERSION";
export const KEY_AFTER_MID = "RUSTCHAT_AFTER_MID";
export const Emojis = ["👍", "❤️", "😄", "👀", "👎", "🎉", "🙁", "🚀"];
export default BASE_URL;
@@ -27,6 +27,16 @@ export default async function handler({ operation, data = {}, payload }) {
await table.setItem("afterMid", afterMid);
}
break;
case "updateReadChannels":
{
await table.setItem("readChannels", data.readChannels);
}
break;
case "updateReadUsers":
{
await table.setItem("readUsers", data.readUsers);
}
break;
default:
break;
}
@@ -0,0 +1,18 @@
// import clearTable from "./clear.handler";
import { updateOnline } from "../slices/ui";
export default async function handler({ dispatch, operation }) {
switch (operation) {
case "offline":
{
dispatch(updateOnline(false));
}
break;
case "online":
{
dispatch(updateOnline(true));
}
break;
default:
break;
}
}
@@ -0,0 +1,18 @@
import clearTable from "./clear.handler";
export default async function handler({ operation, payload }) {
const table = window.CACHE["server"];
if (operation.startsWith("reset")) {
clearTable("server");
return;
}
switch (operation) {
case "updateInviteLink":
{
const data = payload;
await table.setItem("inviteLink", data);
}
break;
default:
break;
}
}
+25 -7
View File
@@ -1,12 +1,15 @@
import { createListenerMiddleware } from "@reduxjs/toolkit";
import rtkqHandler from "./handler.rtkq";
import channelsHandler from "./handler.channels";
import contactsHandler from "./handler.contacts";
import channelMsgHandler from "./handler.channel.msg";
import dmMsgHandler from "./handler.dm.msg";
import serverHandler from "./handler.server";
import messageHandler from "./handler.message";
import reactionHandler from "./handler.reaction";
import footprintHandler from "./handler.footprint";
const operations = [
"__rtkq",
"channels",
"channelMessage",
"contacts",
@@ -24,23 +27,29 @@ const listenerMiddleware = createListenerMiddleware();
listenerMiddleware.startListening({
predicate: (action, currentState, previousState) => {
const { type = "" } = action;
console.log("operation", type);
const [prefix] = type.split("/");
console.log("operation", type, operations.includes(prefix));
return operations.includes(prefix);
// console.log("listener predicate", action, currentState, previousState);
// return true;
},
effect: async (action, listenerApi) => {
if (!window.CACHE) return;
const { type = "", payload } = action;
// const key = getCacheKey(type);
// // console.log("listener effect", key, listenerApi.getState(), window.CACHE);
// if (key && window.CACHE) {
// await window.CACHE.setItem(key, listenerApi.getState()[key]);
// }
const [prefix, operation] = type.split("/");
console.log("effect opt", action);
if (!window.CACHE && prefix !== "__rtkq") return;
const state = listenerApi.getState()[prefix];
switch (prefix) {
case "__rtkq":
{
rtkqHandler({
operation,
payload,
dispatch: listenerApi.dispatch,
});
}
break;
case "channels":
{
await channelsHandler({
@@ -104,6 +113,15 @@ listenerMiddleware.startListening({
});
}
break;
case "server":
{
await serverHandler({
operation,
payload,
data: state,
});
}
break;
default:
break;
+15 -2
View File
@@ -1,6 +1,15 @@
import { createApi } from "@reduxjs/toolkit/query/react";
import { nanoid } from "@reduxjs/toolkit";
import baseQuery from "./base.query";
import BASE_URL from "../config";
import BASE_URL, { KEY_DEVICE_KEY } from "../config";
const getDeviceId = () => {
let d = localStorage.getItem(KEY_DEVICE_KEY);
if (!d) {
d = `web:${nanoid()}`;
localStorage.setItem(KEY_DEVICE_KEY, d);
}
return d;
};
export const authApi = createApi({
reducerPath: "authApi",
baseQuery,
@@ -9,7 +18,11 @@ export const authApi = createApi({
query: (credentials) => ({
url: "token/login",
method: "POST",
body: { credential: credentials, device: "web", device_token: "test" },
body: {
credential: credentials,
device: getDeviceId(),
device_token: "test",
},
}),
transformResponse: (data) => {
const { avatar_updated_at } = data.user;
+8 -2
View File
@@ -5,6 +5,7 @@ import { updateToken, resetAuthData } from "../slices/auth.data";
import BASE_URL, { tokenHeader } from "../config";
const whiteList = [
"login",
"register",
"checkInviteTokenValid",
"getServer",
"getOpenid",
@@ -64,8 +65,13 @@ const baseQueryWithTokenCheck = async (args, api, extraOptions) => {
result = await baseQuery(args, api, extraOptions);
}
if (result?.error) {
console.log("api error", result.error.originalStatus, api.endpoint);
console.log("api error", result.error, api.endpoint);
switch (result.error.originalStatus || result.error.status) {
case "FETCH_ERROR":
{
toast.error(`${api.endpoint}: Failed to fetch`);
}
break;
case 404:
{
toast.error("Request Not Found");
@@ -84,7 +90,7 @@ const baseQueryWithTokenCheck = async (args, api, extraOptions) => {
location.href = "/#/login";
// } else {
toast.error("API Not Authenticated");
return;
// return;
// }
}
break;
+8
View File
@@ -61,6 +61,13 @@ export const channelApi = createApi({
await onMessageSendStarted.call(this, param1, param2, "channel");
},
}),
addMembers: builder.mutation({
query: ({ id, members }) => ({
url: `group/${id}/members/add`,
method: "POST",
body: members,
}),
}),
}),
});
@@ -71,4 +78,5 @@ export const {
useGetChannelsQuery,
useCreateChannelMutation,
useSendChannelMsgMutation,
useAddMembersMutation,
} = channelApi;
+34
View File
@@ -1,5 +1,8 @@
import { createApi } from "@reduxjs/toolkit/query/react";
import { batch } from "react-redux";
import { ContentTypes } from "../config";
import { updateReadChannels, updateReadUsers } from "../slices/footprint";
import { onMessageSendStarted } from "./handlers";
// import { updateMessage } from "../slices/message";
@@ -48,6 +51,36 @@ export const messageApi = createApi({
await onMessageSendStarted.call(this, param1, param2, param1.context);
},
}),
readMessage: builder.mutation({
query: (data) => ({
url: `/user/read-index`,
method: "POST",
body: data,
}),
async onQueryStarted(data, { dispatch, getState, queryFulfilled }) {
const { users = [], groups = [] } = data;
const { readUsers, readChannels } = getState().footprint.readUsers;
const prevUsers = users.map(({ uid }) => {
return { uid, mid: readUsers[uid] };
});
const prevChannels = users.map(({ gid }) => {
return { gid, mid: readChannels[gid] };
});
batch(() => {
dispatch(updateReadChannels(groups));
dispatch(updateReadUsers(users));
});
try {
await queryFulfilled;
} catch {
// todo
batch(() => {
dispatch(updateReadChannels(prevChannels));
dispatch(updateReadUsers(prevUsers));
});
}
},
}),
}),
});
@@ -56,4 +89,5 @@ export const {
useReactMessageMutation,
useReplyMessageMutation,
useLazyDeleteMessageQuery,
useReadMessageMutation,
} = messageApi;
+25 -1
View File
@@ -1,6 +1,6 @@
import { createApi } from "@reduxjs/toolkit/query/react";
import BASE_URL from "../config";
import { updateInviteLink } from "../slices/server";
import baseQuery from "./base.query";
export const serverApi = createApi({
@@ -57,6 +57,29 @@ export const serverApi = createApi({
body: data,
}),
}),
createInviteLink: builder.query({
query: (expired_in = 7 * 24 * 60 * 60) => ({
headers: {
"content-type": "text/plain",
accept: "text/plain",
},
url: `/admin/user/create_invite_link?expired_in=${expired_in}`,
responseHandler: (response) => response.text(),
}),
async onQueryStarted(expire, { dispatch, queryFulfilled, getState }) {
const {
expire: prevExp,
link: prevLink,
} = getState().server.inviteLink;
try {
const { data: link } = await queryFulfilled;
console.log("link", link);
dispatch(updateInviteLink({ expire, link }));
} catch {
dispatch(updateInviteLink({ expire: prevExp, link: prevLink }));
}
},
}),
updateServer: builder.mutation({
query: (data) => ({
url: `admin/system/organization`,
@@ -79,4 +102,5 @@ export const {
useLazyGetServerQuery,
useUpdateServerMutation,
useUpdateLogoMutation,
useLazyCreateInviteLinkQuery,
} = serverApi;
+5 -4
View File
@@ -35,13 +35,14 @@ const handler = (data, dispatch, currState) => {
dispatch(updateAfterMid(mid));
break;
}
const { ready, loginUid } = currState;
const { ready, loginUid, readUsers = {}, readChannels = {} } = currState;
const to = typeof target.gid !== "undefined" ? "channel" : "user";
const appendMessage = to == "user" ? addUserMsg : addChannelMsg;
const self = from_uid == loginUid;
// 此处有点绕
const id = to == "user" ? (self ? target.uid : from_uid) : target.gid;
const readIndex = (to == "user" ? readUsers[id] : readChannels[id]) || 0;
const read = self ? true : mid < readIndex ? true : false;
switch (type) {
case "normal":
{
@@ -50,7 +51,7 @@ const handler = (data, dispatch, currState) => {
addMessage({
mid,
// 如果是自己发的消息,就是已读
read: self,
read,
...common,
})
);
@@ -70,7 +71,7 @@ const handler = (data, dispatch, currState) => {
mid,
reply_mid: detailMid,
// 如果是自己发的消息,就是已读
read: self,
read,
...common,
})
);
+27 -4
View File
@@ -9,7 +9,11 @@ import {
addChannel,
removeChannel,
} from "../../slices/channels";
import { updateUsersVersion } from "../../slices/footprint";
import {
updateUsersVersion,
updateReadChannels,
updateReadUsers,
} from "../../slices/footprint";
import { updateUsersByLogs, updateUsersStatus } from "../../slices/contacts";
import { resetAuthData } from "../../slices/auth.data";
import baseQuery from "../base.query";
@@ -58,6 +62,7 @@ export const streamingApi = createApi({
const {
ui: { ready },
authData: { uid: loginUid },
footprint: { readUsers, readChannels },
} = getState();
const data = JSON.parse(evt.data);
const { type } = data;
@@ -83,6 +88,18 @@ export const streamingApi = createApi({
dispatch(updateUsersByLogs(logs));
}
break;
case "user_settings":
case "user_settings_changed":
{
console.log("users settings");
const {
read_index_users = [],
read_index_groups = [],
} = data;
dispatch(updateReadChannels(read_index_groups));
dispatch(updateReadUsers(read_index_users));
}
break;
case "users_state":
case "users_state_changed":
{
@@ -123,7 +140,12 @@ export const streamingApi = createApi({
break;
case "chat":
{
chatMessageHandler(data, dispatch, { ready, loginUid });
chatMessageHandler(data, dispatch, {
ready,
loginUid,
readUsers,
readChannels,
});
}
break;
@@ -132,8 +154,8 @@ export const streamingApi = createApi({
break;
}
};
streaming.onopen = () => {
console.info("sse opened");
streaming.onopen = (wtf) => {
console.info("sse opened", wtf);
};
streaming.onerror = (err) => {
switch (err.eventPhase) {
@@ -148,6 +170,7 @@ export const streamingApi = createApi({
break;
default:
streaming.close();
console.error("sse error error", err);
// renewToken({ token, refreshToken });
break;
+7 -1
View File
@@ -6,6 +6,12 @@ const initialState = {
expireTime: localStorage.getItem(KEY_EXPIRE) || new Date().getTime(),
refreshToken: localStorage.getItem(KEY_REFRESH_TOKEN),
};
const emptyState = {
uid: null,
token: null,
expireTime: new Date().getTime(),
refreshToken: null,
};
const authDataSlice = createSlice({
name: "authData",
initialState,
@@ -37,7 +43,7 @@ const authDataSlice = createSlice({
localStorage.removeItem(KEY_TOKEN);
localStorage.removeItem(KEY_REFRESH_TOKEN);
localStorage.removeItem(KEY_UID);
return initialState;
return emptyState;
},
setUid(state, action) {
const uid = action.payload;
+6
View File
@@ -1,5 +1,6 @@
import { createSlice } from "@reduxjs/toolkit";
import { getNonNullValues } from "../../common/utils";
import BASE_URL from "../config";
const initialState = {
ids: [],
byId: {},
@@ -37,6 +38,11 @@ const contactsSlice = createSlice({
if (state.byId[uid]) {
Object.keys(vals).forEach((k) => {
state.byId[uid][k] = vals[k];
if (k == "avatar_updated_at") {
state.byId[
uid
].avatar = `${BASE_URL}/resource/avatar?uid=${uid}&t=${vals[k]}`;
}
});
}
}
+25 -1
View File
@@ -2,6 +2,8 @@ import { createSlice } from "@reduxjs/toolkit";
const initialState = {
usersVersion: 0,
afterMid: 0,
readUsers: {},
readChannels: {},
};
const footprintSlice = createSlice({
name: "footprint",
@@ -11,7 +13,13 @@ const footprintSlice = createSlice({
return initialState;
},
fullfillFootprint(state, action) {
return action.payload;
const {
usersVersion = 0,
afterMid = 0,
readUsers = {},
readChannels = {},
} = action.payload;
return { usersVersion, afterMid, readUsers, readChannels };
},
updateUsersVersion(state, action) {
const usersVersion = action.payload;
@@ -21,6 +29,20 @@ const footprintSlice = createSlice({
const afterMid = action.payload;
state.afterMid = afterMid;
},
updateReadUsers(state, action) {
const reads = action.payload || [];
if (reads.length == 0) return;
reads.forEach(({ uid, mid }) => {
state.readUsers[uid] = mid;
});
},
updateReadChannels(state, action) {
const reads = action.payload || [];
if (reads.length == 0) return;
reads.forEach(({ gid, mid }) => {
state.readChannels[gid] = mid;
});
},
},
});
export const {
@@ -28,5 +50,7 @@ export const {
fullfillFootprint,
updateAfterMid,
updateUsersVersion,
updateReadChannels,
updateReadUsers,
} = footprintSlice.actions;
export default footprintSlice.reducer;
+2 -2
View File
@@ -15,9 +15,9 @@ const channelMsgSlice = createSlice({
const { id, mid } = action.payload;
if (state[id]) {
if (state[id].findIndex((id) => id == mid) > -1) return;
state[id].push(mid);
state[id].push(+mid);
} else {
state[id] = [mid];
state[id] = [+mid];
}
},
removeChannelMsg(state, action) {
+4
View File
@@ -22,6 +22,10 @@ const reactionMessageSlice = createSlice({
const idx = reactionUids.findIndex((id) => id == from_uid);
if (idx > -1) {
reactionUids.splice(idx, 1);
if (reactionUids.length == 0) {
// 没有表情了
delete state[mid][reaction];
}
} else {
reactionUids.push(from_uid);
}
+3 -3
View File
@@ -18,10 +18,10 @@ const userMsgSlice = createSlice({
const { id, mid } = action.payload;
if (state.byId[id]) {
if (state.byId[id].findIndex((id) => id == mid) > -1) return;
state.byId[id].push(mid);
state.byId[id].push(+mid);
} else {
state.byId[id] = [mid];
state.ids.push(id);
state.byId[id] = [+mid];
state.ids.push(+id);
}
},
removeUserMsg(state, action) {
+35
View File
@@ -0,0 +1,35 @@
import { createSlice } from "@reduxjs/toolkit";
const initialState = {
inviteLink: {
link: "",
expire: 0,
},
};
const serverSlice = createSlice({
name: "server",
initialState,
reducers: {
resetServer() {
return initialState;
},
fullfillServer(state, action) {
const {
inviteLink = {
link: "",
expire: 0,
},
} = action.payload;
return { inviteLink };
},
updateInviteLink(state, action) {
const { link, expire = 7 * 24 * 60 * 60 } = action.payload;
state.inviteLink = { link, expire };
},
},
});
export const {
resetServer,
fullfillServer,
updateInviteLink,
} = serverSlice.actions;
export default serverSlice.reducer;
+5
View File
@@ -1,6 +1,7 @@
import { createSlice } from "@reduxjs/toolkit";
const initialState = {
online: true,
ready: false,
menuExpand: true,
setting: false,
@@ -13,6 +14,9 @@ const uiSlice = createSlice({
setReady(state) {
state.ready = true;
},
updateOnline(state, action) {
state.online = action.payload;
},
toggleMenuExpand(state) {
state.menuExpand = !state.menuExpand;
},
@@ -28,6 +32,7 @@ const uiSlice = createSlice({
});
export const {
setReady,
updateOnline,
toggleSetting,
toggleMenuExpand,
toggleChannelSetting,
+44 -1
View File
@@ -3,6 +3,7 @@ import { setupListeners } from "@reduxjs/toolkit/query";
import listenerMiddleware from "./listener.middleware";
import authDataReducer from "./slices/auth.data";
import footprintReducer from "./slices/footprint";
import serverReducer from "./slices/server";
import uiReducer from "./slices/ui";
import channelsReducer from "./slices/channels";
import contactsReducer from "./slices/contacts";
@@ -21,6 +22,7 @@ const reducer = combineReducers({
authData: authDataReducer,
ui: uiReducer,
footprint: footprintReducer,
server: serverReducer,
contacts: contactsReducer,
channels: channelsReducer,
reactionMessage: reactionMsgReducer,
@@ -49,5 +51,46 @@ const store = configureStore({
)
.prepend(listenerMiddleware.middleware),
});
setupListeners(store.dispatch);
let initialized = false;
setupListeners(
store.dispatch,
(dispatch, { onOnline, onOffline, onFocus, onFocusLost }) => {
const handleFocus = () => dispatch(onFocus());
const handleFocusLost = () => dispatch(onFocusLost());
const handleOnline = () => dispatch(onOnline());
const handleOffline = () => dispatch(onOffline());
const handleVisibilityChange = () => {
if (window.document.visibilityState === "visible") {
handleFocus();
} else {
handleFocusLost();
}
};
if (!initialized) {
if (typeof window !== "undefined" && window.addEventListener) {
// Handle focus events
window.addEventListener(
"visibilitychange",
handleVisibilityChange,
false
);
window.addEventListener("focus", handleFocus, false);
// Handle connection events
window.addEventListener("online", handleOnline, false);
window.addEventListener("offline", handleOffline, false);
initialized = true;
}
}
const unsubscribe = () => {
window.removeEventListener("focus", handleFocus);
window.removeEventListener("visibilitychange", handleVisibilityChange);
window.removeEventListener("online", handleOnline);
window.removeEventListener("offline", handleOffline);
initialized = false;
};
return unsubscribe;
}
);
export default store;
+4
View File
@@ -0,0 +1,4 @@
:root{
/* border radius */
--br:8px
}
+3 -2
View File
@@ -23,7 +23,7 @@ export default function ChannelModal({ personal = false, closeModal }) {
const [data, setData] = useState({
name: "",
dsecription: "",
members: [],
members: [loginUid],
is_public: !personal,
});
const { contacts, input, updateInput } = useFilteredUsers();
@@ -111,9 +111,10 @@ export default function ChannelModal({ personal = false, closeModal }) {
key={uid}
data-uid={uid}
className="user"
onClick={toggleCheckMember}
onClick={loginUid == uid ? null : toggleCheckMember}
>
<StyledCheckbox
disabled={loginUid == uid}
readOnly
checked={checked}
name="cb"
+1 -1
View File
@@ -12,7 +12,7 @@ const StyledWrapper = styled.div`
justify-content: flex-start;
gap: 8px;
padding: 8px;
border-radius: 4px;
border-radius: 8px;
user-select: none;
&.compact {
padding: 0;
+1 -1
View File
@@ -57,7 +57,7 @@ export default function ContactsModal({ closeModal }) {
useOutsideClick(wrapperRef, closeModal);
const handleSearch = (evt) => {
console.log("www");
// updateInput(evt.target.value);
updateInput(evt.target.value);
};
return (
+4 -10
View File
@@ -1,6 +1,8 @@
// import { useEffect } from "react";
import styled from "styled-components";
import { useSelector } from "react-redux";
import soundIcon from "../../assets/icons/sound.on.svg?url";
import micIcon from "../../assets/icons/mic.on.svg?url";
import Avatar from "./Avatar";
const StyledWrapper = styled.div`
background-color: #e5e5e5;
@@ -75,16 +77,8 @@ export default function CurrentUser() {
</div>
{/* {expand && ( */}
<div className="settings">
<img
src="https://static.nicegoodthings.com/project/rustchat/icon.speaker.svg"
className="icon"
alt="mic icon"
/>
<img
src="https://static.nicegoodthings.com/project/rustchat/icon.mic.svg"
className="icon"
alt="sound icon"
/>
<img src={soundIcon} className="icon" alt="mic icon" />
<img src={micIcon} className="icon" alt="sound icon" />
</div>
{/* )} */}
</StyledWrapper>
+27
View File
@@ -0,0 +1,27 @@
// import React from "react";
import styled from "styled-components";
const StyledDivider = styled.hr`
display: block;
position: relative;
border: 0;
border-top: 1px solid #e3e5e8;
margin: 25px 0;
&:before {
background: #fff;
padding: 2px 4px;
position: absolute;
left: 50%;
top: 50%;
transform: translate3d(-50%, -50%, 0);
content: attr(data-content);
font-style: normal;
font-weight: 600;
font-size: 12px;
line-height: 18px;
color: #78787c;
}
`;
export default function Divider({ content }) {
if (!content) return null;
return <StyledDivider data-content={content}></StyledDivider>;
}
+11
View File
@@ -0,0 +1,11 @@
import { getEmojiDataFromNative, Emoji } from "emoji-mart";
import data from "emoji-mart/data/all.json";
export default function EmojiItem({ native = "", set = "twitter", size = 16 }) {
if (!native) return null;
const emojiData = getEmojiDataFromNative(native, "apple", data);
return (
<Emoji emoji={emojiData} set={set} skin={emojiData.skin || 1} size={size} />
);
}
+25
View File
@@ -0,0 +1,25 @@
// import React from 'react'
import { Picker } from "emoji-mart";
import "emoji-mart/css/emoji-mart.css";
import styled from "styled-components";
const StyledWrapper = styled.div`
filter: drop-shadow(0px 25px 50px rgba(31, 41, 55, 0.25));
border-radius: 12px;
.emoji-mart {
border: none;
border-radius: 12px;
}
`;
export default function EmojiPicker({ onSelect, ...rest }) {
return (
<StyledWrapper>
<Picker
// set="twitter"
showPreview={false}
showSkinTones={false}
onSelect={onSelect}
{...rest}
/>
</StyledWrapper>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { useEffect } from "react";
import styled from "styled-components";
import { useSelector } from "react-redux";
import { useLazyCreateInviteLinkQuery } from "../../app/services/server";
import Button from "./styled/Button";
import useCopy from "../hook/useCopy";
const StyledWrapper = styled.div`
display: flex;
flex-direction: column;
align-items: flex-start;
padding-bottom: 32px;
.tip {
font-weight: 500;
font-size: 14px;
color: #6b7280;
margin-bottom: 8px;
}
.link {
margin-bottom: 12px;
display: flex;
justify-content: space-between;
align-items: center;
background: #ffffff;
border: 1px solid #e5e7eb;
box-shadow: 0px 1px 2px rgba(31, 41, 55, 0.08);
border-radius: 4px;
padding: 3px 4px 3px 8px;
.content {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 512px;
font-weight: 400;
font-size: 14px;
line-height: 20px;
color: #78787c;
}
}
.sub_tip {
margin-left: 4px;
font-weight: 400;
font-size: 12px;
line-height: 18px;
color: #616161;
margin-bottom: 20px;
}
`;
export default function InviteLink() {
const [copid, copy] = useCopy();
const {
inviteLink: { link },
loginUser,
} = useSelector((store) => {
return {
inviteLink: store.server.inviteLink,
loginUser: store.contacts.byId[store.authData.uid],
};
});
const [createLink, { isLoading }] = useLazyCreateInviteLinkQuery();
useEffect(() => {
if (!link && loginUser && loginUser.is_admin) {
createLink();
}
}, [link, loginUser]);
const handleNewLink = () => {
createLink();
};
if (!loginUser || !loginUser.is_admin) return null;
return (
<StyledWrapper>
<span className="tip">
Share this link to invite people to this server.
</span>
<div className="link">
<span title={link} className="content">
{link}
</span>
<Button onClick={copy.bind(null, link)} className="main">
{copid ? "Copied" : `Copy`}
</Button>
</div>
<span className="sub_tip">Invite link expires in 7 days.</span>
<Button className="ghost" disabled={isLoading} onClick={handleNewLink}>
{isLoading ? `Generating` : `Generate New Link`}
</Button>
</StyledWrapper>
);
}
@@ -0,0 +1,74 @@
import React from "react";
import { cx, css } from "@emotion/css";
export const Button = React.forwardRef(
({ className, active, reversed, ...props }, ref) => (
<span
{...props}
ref={ref}
className={cx(
className,
css`
cursor: pointer;
color: ${reversed
? active
? "white"
: "#aaa"
: active
? "black"
: "#ccc"};
`
)}
/>
)
);
export const Icon = React.forwardRef(({ className, ...props }, ref) => (
<span
{...props}
ref={ref}
className={cx(
"material-icons",
className,
css`
font-size: 18px;
vertical-align: text-bottom;
`
)}
/>
));
export const Menu = React.forwardRef(({ className, ...props }, ref) => (
<div
{...props}
ref={ref}
className={cx(
className,
css`
& > * {
display: inline-block;
}
& > * + * {
margin-left: 15px;
}
`
)}
/>
));
export const Toolbar = React.forwardRef(({ className, ...props }, ref) => (
<Menu
{...props}
ref={ref}
className={cx(
className,
css`
position: relative;
padding: 5px 16px;
margin: 0 -20px;
border-bottom: 1px solid #fff;
margin-bottom: 20px;
`
)}
/>
));
@@ -0,0 +1,31 @@
import Editor from "rich-markdown-editor";
import styled from "styled-components";
const Styled = styled.div`
padding-top: 8px;
.mde {
padding: 4px;
height: 150px;
> div {
height: 100%;
.ProseMirror {
overflow: scroll;
height: 100%;
max-height: 300px;
padding: 5px 28px;
}
}
}
`;
export default function MarkdownEditer({ value, updateValue }) {
return (
<Styled>
<Editor
defaultValue={value}
onChange={updateValue}
autoFocus={true}
placeholder="Type '/' to insert..."
className="mde"
></Editor>
</Styled>
);
}
@@ -0,0 +1,29 @@
import * as React from "react";
import ReactMde from "react-mde";
import * as Showdown from "showdown";
import "react-mde/lib/styles/css/react-mde-all.css";
const converter = new Showdown.Converter({
tables: true,
simplifiedAutoLink: true,
strikethrough: true,
tasklists: true,
});
export default function MarkdownEditer() {
const [value, setValue] = React.useState("**Hello world!!!**");
const [selectedTab, setSelectedTab] = React.useState("write");
return (
<div className="container">
<ReactMde
value={value}
onChange={setValue}
selectedTab={selectedTab}
onTabChange={setSelectedTab}
generateMarkdownPreview={(markdown) =>
Promise.resolve(converter.makeHtml(markdown))
}
/>
</div>
);
}
@@ -0,0 +1,280 @@
import { useCallback, useMemo, useState } from "react";
import isHotkey from "is-hotkey";
import { Editable, withReact, useSlate, Slate } from "slate-react";
import {
Editor,
Transforms,
createEditor,
Element as SlateElement,
} from "slate";
import { withHistory } from "slate-history";
import Styled from "./styled";
import { Button, Icon, Toolbar } from "./components";
const HOTKEYS = {
"mod+b": "bold",
"mod+i": "italic",
"mod+u": "underline",
"mod+`": "code",
};
const LIST_TYPES = ["numbered-list", "bulleted-list"];
const TEXT_ALIGN_TYPES = ["left", "center", "right", "justify"];
const MarkdownEditor = () => {
const [value, setValue] = useState(initialValue);
const renderElement = useCallback((props) => <Element {...props} />, []);
const renderLeaf = useCallback((props) => <Leaf {...props} />, []);
const editor = useMemo(() => withHistory(withReact(createEditor())), []);
return (
<Styled>
<Slate
editor={editor}
value={value}
onChange={(value) => setValue(value)}
>
<Toolbar>
<MarkButton format="bold" icon="format_bold" />
<MarkButton format="italic" icon="format_italic" />
<MarkButton format="underline" icon="format_underlined" />
<MarkButton format="code" icon="code" />
{/* <BlockButton format="heading-one" icon="looks_one" />
<BlockButton format="heading-two" icon="looks_two" /> */}
<BlockButton format="block-quote" icon="format_quote" />
<BlockButton format="numbered-list" icon="format_list_numbered" />
<BlockButton format="bulleted-list" icon="format_list_bulleted" />
<BlockButton format="left" icon="format_align_left" />
<BlockButton format="center" icon="format_align_center" />
<BlockButton format="right" icon="format_align_right" />
<BlockButton format="justify" icon="format_align_justify" />
</Toolbar>
<Editable
renderElement={renderElement}
renderLeaf={renderLeaf}
placeholder="Enter some rich text…"
spellCheck
autoFocus
onKeyDown={(event) => {
for (const hotkey in HOTKEYS) {
if (isHotkey(hotkey, event)) {
event.preventDefault();
const mark = HOTKEYS[hotkey];
toggleMark(editor, mark);
}
}
}}
/>
</Slate>
</Styled>
);
};
const toggleBlock = (editor, format) => {
const isActive = isBlockActive(
editor,
format,
TEXT_ALIGN_TYPES.includes(format) ? "align" : "type"
);
const isList = LIST_TYPES.includes(format);
Transforms.unwrapNodes(editor, {
match: (n) =>
!Editor.isEditor(n) &&
SlateElement.isElement(n) &&
LIST_TYPES.includes(n.type) &&
!TEXT_ALIGN_TYPES.includes(format),
split: true,
});
let newProperties;
if (TEXT_ALIGN_TYPES.includes(format)) {
newProperties = {
align: isActive ? undefined : format,
};
} else {
newProperties = {
type: isActive ? "paragraph" : isList ? "list-item" : format,
};
}
Transforms.setNodes < SlateElement > (editor, newProperties);
if (!isActive && isList) {
const block = { type: format, children: [] };
Transforms.wrapNodes(editor, block);
}
};
const toggleMark = (editor, format) => {
const isActive = isMarkActive(editor, format);
if (isActive) {
Editor.removeMark(editor, format);
} else {
Editor.addMark(editor, format, true);
}
};
const isBlockActive = (editor, format, blockType = "type") => {
const { selection } = editor;
if (!selection) return false;
const [match] = Array.from(
Editor.nodes(editor, {
at: Editor.unhangRange(editor, selection),
match: (n) =>
!Editor.isEditor(n) &&
SlateElement.isElement(n) &&
n[blockType] === format,
})
);
return !!match;
};
const isMarkActive = (editor, format) => {
const marks = Editor.marks(editor);
return marks ? marks[format] === true : false;
};
const Element = ({ attributes, children, element }) => {
const style = { textAlign: element.align };
switch (element.type) {
case "block-quote":
return (
<blockquote style={style} {...attributes}>
{children}
</blockquote>
);
case "bulleted-list":
return (
<ul style={style} {...attributes}>
{children}
</ul>
);
case "heading-one":
return (
<h1 style={style} {...attributes}>
{children}
</h1>
);
case "heading-two":
return (
<h2 style={style} {...attributes}>
{children}
</h2>
);
case "list-item":
return (
<li style={style} {...attributes}>
{children}
</li>
);
case "numbered-list":
return (
<ol style={style} {...attributes}>
{children}
</ol>
);
default:
return (
<p style={style} {...attributes}>
{children}
</p>
);
}
};
const Leaf = ({ attributes, children, leaf }) => {
if (leaf.bold) {
children = <strong>{children}</strong>;
}
if (leaf.code) {
children = <code>{children}</code>;
}
if (leaf.italic) {
children = <em>{children}</em>;
}
if (leaf.underline) {
children = <u>{children}</u>;
}
return <span {...attributes}>{children}</span>;
};
const BlockButton = ({ format, icon }) => {
const editor = useSlate();
return (
<Button
active={isBlockActive(
editor,
format,
TEXT_ALIGN_TYPES.includes(format) ? "align" : "type"
)}
onMouseDown={(event) => {
event.preventDefault();
toggleBlock(editor, format);
}}
>
<Icon>{icon}</Icon>
</Button>
);
};
const MarkButton = ({ format, icon }) => {
const editor = useSlate();
return (
<Button
active={isMarkActive(editor, format)}
onMouseDown={(event) => {
event.preventDefault();
toggleMark(editor, format);
}}
>
<Icon>{icon}</Icon>
</Button>
);
};
const initialValue = [
{
type: "paragraph",
children: [
{ text: "This is editable " },
{ text: "rich", bold: true },
{ text: " text, " },
{ text: "much", italic: true },
{ text: " better than a " },
{ text: "<textarea>", code: true },
{ text: "!" },
],
},
{
type: "paragraph",
children: [
{
text:
"Since it's rich text, you can do things like turn a selection of text ",
},
{ text: "bold", bold: true },
{
text:
", or add a semantically rendered block quote in the middle of the page, like this:",
},
],
},
{
type: "block-quote",
children: [{ text: "A wise quote." }],
},
{
type: "paragraph",
align: "center",
children: [{ text: "Try it out for yourself!" }],
},
];
export default MarkdownEditor;
@@ -0,0 +1,62 @@
import styled from "styled-components";
const Styled = styled.div`
* {
line-height: 1.4;
}
/* ul,
ol {
list-style-position: inside;
display: list-item;
padding-left: 10px;
}
ul {
list-style-type: disc;
}
ol {
list-style-type: decimal;
} */
strong {
font-weight: bold;
}
p {
margin: 0;
margin-bottom: 12px;
}
pre {
padding: 10px;
background-color: #eee;
white-space: pre-wrap;
}
:not(pre) > code {
font-family: monospace;
background-color: #eee;
padding: 3px;
}
code {
background-color: #ccc;
}
img {
max-width: 100%;
max-height: 20em;
}
blockquote {
border-left: 2px solid #ddd;
margin-left: 0;
margin-right: 0;
padding-left: 10px;
color: #aaa;
font-style: italic;
}
blockquote[dir="rtl"] {
border-left: none;
padding-left: 0;
padding-right: 10px;
border-right: 2px solid #ddd;
}
`;
export default Styled;
+31 -31
View File
@@ -1,72 +1,73 @@
// import { Picker } from "emoji-mart";
// import "emoji-mart/css/emoji-mart.css";
// import Picker from "../EmojiPicker";
import { useRef } from "react";
import styled from "styled-components";
import { useOutsideClick } from "rooks";
import { useSelector } from "react-redux";
import { useReactMessageMutation } from "../../../app/services/message";
import { Emojis } from "../../../app/config";
import Emoji from "../Emoji";
const StyledPicker = styled.div`
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 6px;
position: absolute;
left: -10px;
top: 0;
transform: translateX(-100%);
background-color: #fff;
padding: 5px;
.emojis {
display: flex;
gap: 4px;
padding: 4px;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
background: #ffffff;
filter: drop-shadow(0px 25px 50px rgba(31, 41, 55, 0.25));
border-radius: 12px;
&.reacting {
opacity: 0.6;
}
.emoji {
cursor: pointer;
border-radius: 4px;
border-radius: 8px;
padding: 4px;
font-size: 30px;
&:hover,
&.reacted {
background-color: #f3f4f6;
background-color: #f5f6f7;
}
> * {
display: flex;
}
}
}
`;
const emojis = {
["U+1F44D"]: "👍",
["U+1F44C"]: "👌",
["U+2764"]: "❤️",
};
export default function EmojiPicker({ mid, hidePicker }) {
const wrapperRef = useRef(null);
const [reactMessage, { isLoading }] = useReactMessageMutation();
const { reactionData, currUid } = useSelector((store) => {
return {
reactionData: store.reactionMessage[mid],
reactionData: store.reactionMessage[mid] || {},
currUid: store.authData.uid,
};
});
useOutsideClick(wrapperRef, hidePicker);
const handleReact = (action) => {
console.log("react", action);
reactMessage({ mid, action });
const handleReact = (emoji) => {
console.log("react", emoji);
reactMessage({ mid, action: emoji });
hidePicker();
};
return (
<StyledPicker ref={wrapperRef}>
{/* <Picker
onSelect={handleReact}
className={`picker ${isLoading ? "reacting" : ""}`}
/> */}
<ul className={`emojis ${isLoading ? "reacting" : ""}`}>
{Object.entries(emojis).map(([key, emoji]) => {
{Emojis.map((emoji) => {
let reacted =
reactionData &&
reactionData[key] &&
reactionData[key].includes(currUid);
reactionData[emoji] &&
reactionData[emoji].findIndex((id) => id == currUid) > -1;
return (
<li
className={`emoji ${reacted ? "reacted" : ""}`}
key={key}
onClick={handleReact.bind(null, key)}
key={emoji}
onClick={handleReact.bind(null, emoji)}
>
{emoji}
<Emoji native={emoji} size={24} />
</li>
);
})}
@@ -74,4 +75,3 @@ export default function EmojiPicker({ mid, hidePicker }) {
</StyledPicker>
);
}
export { emojis };
+94 -29
View File
@@ -1,54 +1,119 @@
import React from "react";
import { useDispatch, useSelector } from "react-redux";
import { useState } from "react";
import { useSelector } from "react-redux";
import styled from "styled-components";
import { emojis } from "./EmojiPicker";
import Emoji from "../Emoji";
import EmojiPicker from "./EmojiPicker";
import { useReactMessageMutation } from "../../../app/services/message";
// import { Emojis } from "../../../app/config";
import addEmojiIcon from "../../../assets/icons/add.emoji.svg?url";
const StyledWrapper = styled.span`
z-index: 99;
position: relative;
margin-top: 8px;
margin-bottom: 4px;
display: flex;
gap: 8px;
font-size: 16px;
align-items: center;
gap: 4px;
width: fit-content;
/* align-items: center; */
.reaction {
cursor: pointer;
background-color: #ecfdff;
border-radius: 6px;
position: relative;
display: flex;
align-items: center;
gap: 6px;
em {
gap: 4px;
padding: 4px;
> .emoji {
> * {
display: flex;
}
}
&:hover {
background-color: #cff9fe;
}
&.reacted {
border: 1px solid #06aed4;
background-color: #a5f0fc;
}
> .count {
font-weight: 400;
font-size: 12px;
color: #999;
line-height: 16px;
color: #06aed4;
}
}
> .add {
visibility: hidden;
width: 24px;
height: 24px;
background-color: #ecfdff;
border-radius: 6px;
border: none;
background-image: url(${addEmojiIcon});
background-size: 16px;
background-repeat: no-repeat;
background-position: center;
&:hover {
background-color: #cff9fe;
}
}
.picker {
position: absolute;
right: 0;
top: 0;
transform: translateX(105%);
}
&:hover > .add {
visibility: visible;
}
`;
export default function Reaction({ reactions = null }) {
// const {
// messageData,
// reactionMessageData,
// contactsData,
// loginedUser,
// } = useSelector((store) => {
// return {
// reactionMessageData: store.reactionMessage,
// messageData: store.message,
// contactsData: store.contacts.byId,
// loginedUser: store.authData.user,
// };
// });
if (!reactions) return null;
export default function Reaction({ mid, reactions = null }) {
const [pickerVisible, setPickerVisible] = useState(false);
const [reactWithEmoji] = useReactMessageMutation();
const { currUid } = useSelector((store) => {
return {
currUid: store.authData.uid,
};
});
const handleReact = (emoji) => {
reactWithEmoji({ mid, action: emoji });
};
const togglePickerVisible = (wtf) => {
console.log("clicked", wtf);
setPickerVisible((prev) => !prev);
};
console.log("curr reactions", reactions);
if (!reactions || Object.entries(reactions).length == 0) return null;
return (
<StyledWrapper className="reactions">
{Object.entries(reactions).map(([reaction, uids]) => {
const reacted = uids.findIndex((id) => id == currUid) > -1;
return uids.length > 0 ? (
<i
className="reaction"
<span
onClick={handleReact.bind(null, reaction)}
className={`reaction ${reacted ? "reacted" : ""}`}
// data-count={count > 1 ? count : ""}
key={reaction}
>
{emojis[reaction]}
<i className="emoji">
<Emoji native={reaction} />
</i>
{uids.length > 1 ? <em>{`+${uids.length}`} </em> : null}
</i>
{uids.length > 1 ? (
<em className="count">{`${uids.length}`} </em>
) : null}
</span>
) : null;
})}
<button onClick={togglePickerVisible} className="add"></button>
{pickerVisible && (
<div className="picker">
<EmojiPicker mid={mid} hidePicker={togglePickerVisible} />
</div>
)}
</StyledWrapper>
);
}
+12 -4
View File
@@ -8,12 +8,14 @@ import Reply from "./Reply";
import Profile from "../Profile";
import Avatar from "../Avatar";
import { readMessage } from "../../../app/slices/message";
import { useReadMessageMutation } from "../../../app/services/message";
import StyledWrapper from "./styled";
import Commands from "./Commands";
import EditMessage from "./EditMessage";
import renderContent from "./renderContent";
function Message({ contextId = 0, mid = "" }) {
function Message({ contextId = 0, mid = "", read = true, context = "user" }) {
const [updateReadIndex] = useReadMessageMutation();
const [myRef, inView] = useInViewRef();
const [edit, setEdit] = useState(false);
const [emojiPopVisible, setEmojiPopVisible] = useState(false);
@@ -47,10 +49,15 @@ function Message({ contextId = 0, mid = "" }) {
// console.log("message", mid, messageData[mid]);
useEffect(() => {
if (inView && !message.read) {
if (inView && !read) {
disptach(readMessage(mid));
const data =
context == "user"
? { users: [{ uid: +contextId, mid }] }
: { groups: [{ gid: +contextId, mid }] };
updateReadIndex(data);
}
}, [mid, message, inView]);
}, [mid, read, inView]);
if (!message) return null;
const {
reply_mid,
@@ -65,6 +72,7 @@ function Message({ contextId = 0, mid = "" }) {
const currUser = contactsData[fromUid] || {};
return (
<StyledWrapper
data-mid={mid}
ref={myRef}
className={`message ${menuVisible ? "menu" : ""} ${
inView ? "in_view" : ""
@@ -85,7 +93,6 @@ function Message({ contextId = 0, mid = "" }) {
<div className="up">
<span className="name">{currUser.name}</span>
<i className="time">{dayjs(time).format("YYYY-MM-DD h:mm:ss A")}</i>
{reactions && <Reaction reactions={reactions} />}
</div>
<div className={`down ${sending ? "sending" : ""}`}>
{edit ? (
@@ -97,6 +104,7 @@ function Message({ contextId = 0, mid = "" }) {
) : (
renderContent(content_type, content, edited)
)}
{reactions && <Reaction mid={mid} reactions={reactions} />}
</div>
</div>
{!edit && (
+12 -1
View File
@@ -1,6 +1,7 @@
import Linkify from "react-linkify";
import dayjs from "dayjs";
import BASE_URL from "../../../app/config";
import MrakdownRender from "../MrakdownRender";
const renderContent = (type, content, edited = false) => {
let ctn = null;
switch (type) {
@@ -9,7 +10,12 @@ const renderContent = (type, content, edited = false) => {
<>
<Linkify
componentDecorator={(decoratedHref, decoratedText, key) => (
<a target="blank" href={decoratedHref} key={key}>
<a
target="_blank"
href={decoratedHref}
key={key}
rel="noreferrer"
>
{decoratedText}
</a>
)}
@@ -27,6 +33,11 @@ const renderContent = (type, content, edited = false) => {
</>
);
break;
case "text/markdown":
{
ctn = <MrakdownRender content={content} />;
}
break;
case "image/png":
case "image/jpeg":
ctn = (
+1 -1
View File
@@ -4,7 +4,7 @@ const StyledMsg = styled.div`
display: flex;
align-items: flex-start;
gap: 16px;
padding: 4px;
padding: 4px 8px;
margin: 8px 0;
border-radius: 8px;
content-visibility: auto;
+154
View File
@@ -0,0 +1,154 @@
// import React from 'react'
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import styled from "styled-components";
const Styled = styled.div`
font-family: "Helvetica Neue", sans;
background: transparent;
color: #222;
line-height: 1.5;
font-size: 16px;
font-weight: 300;
img {
margin: 0;
border: 0;
}
p {
margin: 1em 0;
}
a {
color: #00213d;
}
a:visited {
color: #00213d;
background-color: transparent;
}
a:active {
color: #318100;
background-color: transparent;
}
a:hover {
text-decoration: none;
}
p img {
border: 0;
margin: 0;
}
h1,
h2,
h3,
h4,
h5,
h6 {
color: #003a6b;
background-color: transparent;
margin: 1em 0;
font-weight: normal;
}
h1 {
font-size: 180%;
font-weight: bold;
}
h2 {
font-size: 160%;
}
h3 {
font-size: 140%;
}
h4 {
font-size: 110%;
}
h5 {
font-size: 105%;
}
h6 {
font-size: 100%;
}
dt {
font-style: italic;
}
dd {
margin-bottom: 1.5em;
}
li {
line-height: 1.5em;
}
code {
padding: 0.1em;
font-size: 14px;
font-family: "Menlo", monospace;
background-color: #f5f5f5;
border: 1px solid #efefef;
}
pre {
font-family: "Menlo", monospace;
background-color: #fff;
padding: 0.5em;
line-height: 1.25em;
border: 1px solid #efefef;
border-bottom: 1px solid #ddd;
-webkit-box-shadow: 0 1px 3px 0 #eee;
-moz-box-shadow: 0 1px 3px 0 #eee;
-ms-box-shadow: 0 1px 3px 0 #eee;
box-shadow: 0 1px 3px 0 #eee;
}
pre code {
background-color: transparent;
border-width: 0;
}
blockquote {
border-top: 1px solid #efefef;
border-bottom: 1px solid #ddd;
-webkit-box-shadow: 0 1px 3px 0 #eee;
-moz-box-shadow: 0 1px 3px 0 #eee;
-ms-box-shadow: 0 1px 3px 0 #eee;
box-shadow: 0 1px 3px 0 #eee;
}
table {
border-collapse: collapse;
border: 1px solid #efefef;
border-bottom: 1px solid #ddd;
-webkit-box-shadow: 0 1px 3px 0 #eee;
-moz-box-shadow: 0 1px 3px 0 #eee;
-ms-box-shadow: 0 1px 3px 0 #eee;
box-shadow: 0 1px 3px 0 #eee;
}
td,
th {
border: 1px solid #ddd;
padding: 0.5em;
}
th {
background-color: #f5f5f5;
}
`;
export default function MrakdownRender({ content }) {
return (
<Styled>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</Styled>
);
}
+8 -7
View File
@@ -1,8 +1,9 @@
import { useState } from "react";
import styled from "styled-components";
import { MdSearch, MdAdd, MdMail } from "react-icons/md";
import { useSelector } from "react-redux";
import searchIcon from "../../assets/icons/search.svg?url";
import addIcon from "../../assets/icons/add.svg?url";
import mailIcon from "../../assets/icons/mail.svg?url";
import ChannelIcon from "./ChannelIcon";
import ChannelModal from "./ChannelModal";
import ContactsModal from "./ContactsModal";
@@ -98,14 +99,14 @@ export default function Search() {
<ContactsModal closeModal={toggleContactsModalVisible} />
)}
<div className="search">
<MdSearch size={20} color="#A1A1AA" />
<img src={searchIcon} />
<input placeholder="Search..." className="input" />
</div>
<MdAdd
<img
src={addIcon}
alt="add icon"
className="add"
onClick={togglePopupVisible}
size={24}
color="#A1A1AA"
/>
{popupVisible && (
<ul className="popup">
@@ -126,7 +127,7 @@ export default function Search() {
New Private Channel
</li>
<li className="item" onClick={toggleContactsModalVisible}>
<MdMail size={20} color="#616161" />
<img src={mailIcon} alt="icon mail" />
New Message
</li>
</ul>
+2 -7
View File
@@ -1,6 +1,5 @@
import { useState } from "react";
import { Picker } from "emoji-mart";
import "emoji-mart/css/emoji-mart.css";
import Picker from "../EmojiPicker";
export default function EmojiPicker({ selectEmoji }) {
const [emojiPickerVisible, setEmojiPickerVisible] = useState(false);
@@ -18,11 +17,7 @@ export default function EmojiPicker({ selectEmoji }) {
</button>
{emojiPickerVisible && (
<div className="picker">
<Picker
onSelect={handleSelect}
showPreview={false}
showSkinTones={false}
/>
<Picker onSelect={handleSelect} />
</div>
)}
</>
+111
View File
@@ -0,0 +1,111 @@
// import React from 'react'
import { RiMarkdownLine, RiMarkdownFill } from "react-icons/ri";
import styled from "styled-components";
import Button from "../styled/Button";
import EmojiPicker from "./EmojiPicker";
import addIcon from "../../../assets/icons/add.svg?url";
const Styled = styled.div`
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: 5px;
.left {
display: flex;
align-items: center;
gap: 8px;
.md {
cursor: pointer;
}
.add {
cursor: pointer;
position: relative;
width: 16px;
height: 16px;
input {
opacity: 0;
cursor: pointer;
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
}
.emoji {
position: relative;
.toggle {
font-size: 22px;
border: none;
background: none;
}
.picker {
position: absolute;
top: -20px;
right: -15px;
transform: translateY(-100%);
}
}
}
.right {
display: flex;
align-items: center;
gap: 8px;
.send {
/* font-size: 16px;
font-weight: bold;
background: none; */
}
}
.divider {
width: 1px;
height: 16px;
background-color: #ccc;
margin: 0 4px;
}
`;
export default function Toolbar({
contentType = "text",
updateContentType,
handleUpload,
selectEmoji,
handleSend,
}) {
const toggleMarkdown = () => {
updateContentType(contentType == "markdown" ? "text" : "markdown");
};
return (
<Styled>
<div className="left">
<div className="add">
<img src={addIcon} />
<input
multiple={true}
onChange={handleUpload}
type="file"
name="file"
id="file"
/>
</div>
<div className="divider"></div>
<div className="emoji">
<EmojiPicker selectEmoji={selectEmoji} />
</div>
<div className="md" onClick={toggleMarkdown}>
{contentType == "markdown" ? (
<RiMarkdownFill size={24} />
) : (
<RiMarkdownLine size={24} />
)}
</div>
</div>
<div className="right">
{contentType == "markdown" && (
<Button className="send main" onClick={handleSend}>
Send
</Button>
)}
</div>
</Styled>
);
}
+46 -34
View File
@@ -1,5 +1,4 @@
import { useState, useEffect, useRef } from "react";
import { MdAdd } from "react-icons/md";
import TextareaAutosize from "react-textarea-autosize";
import { useDispatch, useSelector } from "react-redux";
import { useKey } from "rooks";
@@ -11,9 +10,9 @@ import { useReplyMessageMutation } from "../../../app/services/message";
import StyledSend from "./styled";
import useFiles from "./useFiles";
import UploadModal from "./UploadModal";
import EmojiPicker from "./EmojiPicker";
import Replying from "./Replying";
import Toolbar from "./Toolbar";
import MarkdownEditor from "../MarkdownEditer";
const Types = {
channel: "#",
user: "@",
@@ -25,11 +24,13 @@ export default function Send({
id = "",
dragFiles = [],
}) {
const [contentType, setContentType] = useState("text");
const [replyMessage] = useReplyMessageMutation();
const { files, setFiles, resetFiles } = useFiles([]);
const inputRef = useRef();
const [shift, setShift] = useState(false);
const [enter, setEnter] = useState(false);
const [markdown, setMarkdown] = useState("");
const [msg, setMsg] = useState("");
const dispatch = useDispatch();
// 谁发的
@@ -75,7 +76,9 @@ export default function Send({
setMsg((prev) => `${prev}${emoji}`);
};
useEffect(() => {
inputRef.current.focus();
if (inputRef) {
inputRef.current.focus();
}
}, [msg, replying_mid]);
const handleUpload = (evt) => {
setFiles([...evt.target.files]);
@@ -97,43 +100,52 @@ export default function Send({
}
setMsg("");
};
const sendMarkdown = () => {
console.log("markdown", markdown, markdown.endsWith("\\"));
sendMessage({
id,
content: markdown,
from_uid,
type: "markdown",
});
setMarkdown("");
};
return (
<>
<StyledSend className={`send ${replying_mid ? "reply" : ""}`}>
{replying_mid && <Replying mid={replying_mid} id={id} />}
<div className="addon">
<MdAdd size={20} color="#78787C" />
<input
multiple={true}
onChange={handleUpload}
type="file"
name="file"
id="file"
/>
</div>
<div className="input">
<TextareaAutosize
autoFocus
onFocus={(e) =>
e.currentTarget.setSelectionRange(
e.currentTarget.value.length,
e.currentTarget.value.length
)
}
ref={inputRef}
className="content"
maxRows={8}
minRows={1}
onKeyDown={handleInputKeydown}
onChange={handleMsgChange}
value={msg}
placeholder={`${Types[type]}${name} 发消息`}
/>
</div>
<div className="emoji">
<EmojiPicker selectEmoji={selectEmoji} />
{contentType == "markdown" ? (
<MarkdownEditor value={markdown} updateValue={setMarkdown} />
) : (
<TextareaAutosize
autoFocus
onFocus={(e) =>
e.currentTarget.setSelectionRange(
e.currentTarget.value.length,
e.currentTarget.value.length
)
}
ref={inputRef}
className="content"
maxRows={8}
minRows={1}
onKeyDown={handleInputKeydown}
onChange={handleMsgChange}
value={msg}
placeholder={`Send to ${Types[type]}${name}`}
/>
)}
</div>
<Toolbar
handleSend={sendMarkdown}
contentType={contentType}
updateContentType={setContentType}
selectEmoji={selectEmoji}
handleUpload={handleUpload}
/>
</StyledSend>
{files.length !== 0 && (
<UploadModal
+4 -29
View File
@@ -10,27 +10,15 @@ const StyledSend = styled.div`
width: calc(100% - 32px);
min-height: 54px;
display: flex;
align-items: center;
gap: 18px;
flex-direction: column;
align-items: flex-start;
gap: 5px;
padding: 4px 18px;
/* margin: 0 16px; */
&.reply {
border-top-left-radius: 0;
border-top-right-radius: 0;
}
.addon {
cursor: pointer;
position: relative;
input {
opacity: 0;
cursor: pointer;
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
}
}
.input {
width: 100%;
position: relative;
@@ -61,20 +49,7 @@ const StyledSend = styled.div`
transform: translateY(-50%);
}
}
.emoji {
position: relative;
.toggle {
font-size: 22px;
border: none;
background: none;
}
.picker {
position: absolute;
top: -20px;
right: -15px;
transform: translateY(-100%);
}
}
.reply {
display: flex;
justify-content: space-between;
@@ -1,7 +1,7 @@
// import React from 'react'
import styled from "styled-components";
import Modal from "./Modal";
import backIcon from "../../assets/icons/arrow.left.svg?url";
const StyledWrapper = styled.div`
width: 100%;
height: 100%;
@@ -18,7 +18,7 @@ const StyledWrapper = styled.div`
color: #1c1c1e;
margin-bottom: 32px;
padding-left: 24px;
background: url(https://static.nicegoodthings.com/project/rustchat/icon.arrow.left.svg);
background: url(${backIcon});
background-size: 16px;
background-repeat: no-repeat;
background-position: left;
+6 -1
View File
@@ -5,7 +5,7 @@ const StyledButton = styled.button`
background: none;
border: 1px solid #e5e7eb;
box-shadow: 0px 1px 2px rgba(31, 41, 55, 0.08);
border-radius: 4px;
border-radius: var(--br, 4px);
font-weight: 500;
color: #374151;
&.main {
@@ -18,6 +18,11 @@ const StyledButton = styled.button`
background: #ef4444;
color: #fff;
}
&.ghost {
border-color: #1fe1f9;
background: none;
color: #1fe1f9;
}
`;
export default StyledButton;
+6 -1
View File
@@ -6,7 +6,6 @@ const Styled = styled.input`
appearance: none;
/* Not removed via appearance */
margin: 0;
/* color: #1fe1f9; */
width: 20px;
height: 20px;
border: 1px solid #d0d5dd;
@@ -30,6 +29,12 @@ const Styled = styled.input`
transform: scale(1);
}
}
&:disabled {
border-color: #ccc;
&::before {
box-shadow: inset 10px 10px #ccc;
}
}
`;
export default function StyledCheckbox(props) {
return <Styled {...props} type="checkbox" />;
@@ -0,0 +1,138 @@
import { batch } from "react-redux";
import {
addChannelMsg,
removeChannelMsg,
} from "../../../app/slices/message.channel";
import {
addMessage,
removeMessage,
updateMessage,
} from "../../../app/slices/message";
import { toggleReactionMessage } from "../../../app/slices/message.reaction";
import { addUserMsg, removeUserMsg } from "../../../app/slices/message.user";
import { updateAfterMid } from "../../../app/slices/footprint";
const handler = (data, dispatch, currState) => {
const {
mid,
from_uid,
created_at,
target,
detail: {
mid: detailMid,
content,
content_type,
type,
properties,
expires_in,
detail: innerDetail,
},
} = data;
const common = {
from_uid,
created_at,
content,
content_type,
properties,
expires_in,
};
switch (type) {
case "normal":
case "reply":
// 更新after_mid
dispatch(updateAfterMid(mid));
break;
}
const { ready, loginUid, readUsers = {}, readChannels = {} } = currState;
const to = typeof target.gid !== "undefined" ? "channel" : "user";
const appendMessage = to == "user" ? addUserMsg : addChannelMsg;
const self = from_uid == loginUid;
// 此处有点绕
const id = to == "user" ? (self ? target.uid : from_uid) : target.gid;
const readIndex = (to == "user" ? readUsers[id] : readChannels[id]) || 0;
const read = self ? true : mid < readIndex ? true : false;
switch (type) {
case "normal":
{
batch(() => {
dispatch(
addMessage({
mid,
// 如果是自己发的消息,就是已读
read,
...common,
})
);
// 未推送完 or 不是自己发的消息
console.log("curr state", ready, loginUid, common.from_uid);
if (!ready || loginUid != common.from_uid) {
dispatch(appendMessage({ id, mid }));
}
});
}
break;
case "reply":
{
batch(() => {
dispatch(
addMessage({
mid,
reply_mid: detailMid,
// 如果是自己发的消息,就是已读
read,
...common,
})
);
// 未推送完 or 不是自己发的消息
if (!ready || loginUid != common.from_uid) {
dispatch(appendMessage({ id, mid }));
}
});
}
break;
case "reaction":
{
const removeContextMessage =
to == "user" ? removeUserMsg : removeChannelMsg;
const { type, action, content, content_type, properties } = innerDetail;
switch (type) {
case "like":
{
dispatch(
toggleReactionMessage({ from_uid, mid: detailMid, action })
);
}
break;
case "delete":
{
batch(() => {
dispatch(removeContextMessage({ id, mid: detailMid }));
dispatch(removeMessage(detailMid));
});
}
break;
case "edit":
{
dispatch(
updateMessage({
mid: detailMid,
content,
content_type,
properties,
edited: true,
})
);
}
break;
default:
break;
}
}
break;
default:
break;
}
};
export default handler;
+223
View File
@@ -0,0 +1,223 @@
import { useState } from "react";
import {
fetchEventSource,
EventStreamContentType,
} from "@microsoft/fetch-event-source";
import toast from "react-hot-toast";
import BASE_URL from "../../../app/config";
import { setReady } from "../../../app/slices/ui";
import {
fullfillChannels,
addChannel,
removeChannel,
updateChannel,
} from "../../../app/slices/channels";
import {
updateUsersVersion,
updateReadChannels,
updateReadUsers,
} from "../../../app/slices/footprint";
import {
updateUsersByLogs,
updateUsersStatus,
} from "../../../app/slices/contacts";
import { resetAuthData } from "../../../app/slices/auth.data";
import chatMessageHandler from "./chat.handler";
import { useDispatch } from "react-redux";
class RetriableError extends Error {}
class FatalError extends Error {}
const getQueryString = (params = {}) => {
const sp = new URLSearchParams();
Object.entries(params).forEach(([key, val]) => {
if (val) {
sp.append(key, val);
}
});
return sp.toString();
};
const StreamStatus = {
waiting: "waiting",
initializing: "initializing",
streaming: "streaming",
};
export default function useStreaming() {
// const store = useSelector((store) => store);
const dispatch = useDispatch();
const [status, setStatus] = useState(StreamStatus.waiting);
const startStreaming = (store) => {
if (status !== StreamStatus.waiting) return;
const controller = new AbortController();
setStatus(StreamStatus.initializing);
const {
authData: { token, afterMid, usersVersion },
} = store;
fetchEventSource(
`${BASE_URL}/user/events?${getQueryString({
"api-key": token,
users_version: usersVersion,
after_mid: afterMid,
})}`,
{
openWhenHidden: true,
signal: controller.signal,
async onopen(response) {
if (
response.ok &&
response.headers.get("content-type") === EventStreamContentType
) {
console.log("sse everything ok");
setStatus(StreamStatus.streaming);
return; // everything's good
} else if (
response.status >= 400 &&
response.status < 500 &&
response.status !== 429
) {
// 重新登录
if (response.status == 401) {
dispatch(resetAuthData());
return;
}
// client-side errors are usually non-retriable:
throw new FatalError();
} else {
throw new RetriableError();
}
},
onmessage(evt) {
console.log("sse message", evt.data);
// if the server emits an error message, throw an exception
// so it gets handled by the onerror callback below:
if (evt.event === "FatalError") {
throw new FatalError(evt.data);
}
const {
ui: { ready },
authData: { uid: loginUid },
footprint: { readUsers, readChannels },
channels: { byId: channelData },
} = store;
const data = JSON.parse(evt.data);
const { type } = data;
switch (type) {
case "heartbeat":
console.log("heartbeat");
break;
case "ready":
console.log("streaming ready");
dispatch(setReady());
break;
case "users_snapshot":
{
console.log("users snapshot");
const { version } = data;
dispatch(updateUsersVersion(version));
}
break;
case "users_log":
{
console.log("users change logs");
const { logs } = data;
dispatch(updateUsersByLogs(logs));
}
break;
case "user_settings":
case "user_settings_changed":
{
console.log("users settings");
const { read_index_users = [], read_index_groups = [] } = data;
dispatch(updateReadChannels(read_index_groups));
dispatch(updateReadUsers(read_index_users));
}
break;
case "users_state":
case "users_state_changed":
{
let { type, ...rest } = data;
const onlines =
type == "users_state_changed" ? [rest] : rest.users;
dispatch(updateUsersStatus(onlines));
}
break;
case "kick":
{
console.log("kicked");
switch (data.reason) {
case "login_from_other_device":
dispatch(resetAuthData());
toast("kicked from the other device");
break;
case "delete_user":
dispatch(resetAuthData());
toast("sorry, your account has been deleted");
break;
default:
break;
}
}
break;
case "related_groups":
console.log("fullfill channels from streaming", data);
dispatch(fullfillChannels(data.groups));
break;
case "joined_group":
console.log("joined group", data.group);
dispatch(addChannel(data.group));
break;
case "user_joined_group":
console.log("new user joined group", data.gid);
dispatch(
updateChannel({
id: data.gid,
members: [...channelData[data.gid].members, ...data.uid],
})
);
break;
case "kick_from_group":
console.log("kicked from group", data.gid);
dispatch(removeChannel(data.gid));
break;
case "chat":
{
chatMessageHandler(data, dispatch, {
ready,
loginUid,
readUsers,
readChannels,
});
}
break;
default:
console.log("sse event data", data);
break;
}
},
onclose() {
// if the server closes the connection unexpectedly, retry:
throw new RetriableError();
},
onerror(err) {
if (err instanceof FatalError) {
// 重连
setTimeout(() => {
startStreaming(store);
}, 500);
throw err; // rethrow to stop the operation
} else {
// do nothing to automatically retry. You can also
// return a specific retry interval here.
}
},
}
);
// for controlling
return controller;
};
return {
initializing: status == StreamStatus.initializing,
streaming: status == StreamStatus.streaming,
startStreaming,
};
}
+21 -20
View File
@@ -1,25 +1,26 @@
// import React from 'react';
import ReactDOM from 'react-dom';
import { Toaster } from 'react-hot-toast';
import { Helmet } from 'react-helmet';
import { Reset } from 'styled-reset';
import ReactDOM from "react-dom";
import { Toaster } from "react-hot-toast";
import { Helmet } from "react-helmet";
import { Reset } from "styled-reset";
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import 'animate.css';
import ReduxRoutes from './routes';
import BASE_URL from './app/config';
import { DndProvider } from "react-dnd";
import { HTML5Backend } from "react-dnd-html5-backend";
import "./assets/vars.css";
import "animate.css";
import ReduxRoutes from "./routes";
import BASE_URL from "./app/config";
ReactDOM.render(
<>
<Helmet>
<link rel="icon" href={`${BASE_URL}/resource/organization/logo`} />
</Helmet>
<Reset />
<Toaster />
<DndProvider backend={HTML5Backend}>
<ReduxRoutes />
</DndProvider>
</>,
document.getElementById('root')
<>
<Helmet>
<link rel="icon" href={`${BASE_URL}/resource/organization/logo`} />
</Helmet>
<Reset />
<Toaster />
<DndProvider backend={HTML5Backend}>
<ReduxRoutes />
</DndProvider>
</>,
document.getElementById("root")
);
@@ -0,0 +1,213 @@
import { useState, useEffect } from "react";
import styled from "styled-components";
import { useSelector } from "react-redux";
import Modal from "../../../common/component/Modal";
import Button from "../../../common/component/styled/Button";
import Contact from "../../../common/component/Contact";
import StyledCheckbox from "../../../common/component/styled/Checkbox";
import { useAddMembersMutation } from "../../../app/services/channel";
import closeIcon from "../../../assets/icons/close.svg?url";
import toast from "react-hot-toast";
// import useFilteredUsers from "../../../common/hook/useFilteredUsers";
const Styled = styled.div`
padding: 16px;
filter: drop-shadow(0px 25px 50px rgba(31, 41, 55, 0.25));
border-radius: 8px;
background-color: #fff;
min-width: 410px;
> .head {
font-weight: 600;
font-size: 18px;
line-height: 28px;
color: #374151;
margin-bottom: 16px;
display: flex;
justify-content: space-between;
align-items: center;
.close {
width: 12px;
height: 12px;
cursor: pointer;
}
}
> .filter {
width: 376px;
height: 40px;
background: rgba(0, 0, 0, 0.08);
border-radius: 4px;
padding: 6px 8px;
display: flex;
align-items: center;
margin-bottom: 12px;
.selects {
display: flex;
align-items: center;
gap: 5px;
width: 100%;
overflow: scroll;
white-space: nowrap;
&::-webkit-scrollbar {
width: 0; /* Remove scrollbar space */
height: 0; /* Remove scrollbar space */
background: transparent; /* Optional: just make scrollbar invisible */
}
.select {
padding: 4px 6px;
background: #52edff;
border-radius: 4px;
font-weight: 600;
font-size: 14px;
line-height: 20px;
color: #ffffff;
display: flex;
justify-content: space-between;
align-items: center;
gap: 5px;
.close {
cursor: pointer;
width: 12px;
height: 12px;
filter: invert(1);
}
}
}
/* .input{
background: none;
padding: ;
} */
}
.users {
display: flex;
flex-direction: column;
/* height: 260px; */
padding-bottom: 20px;
max-height: 364px;
overflow: scroll;
.user {
cursor: pointer;
display: flex;
align-items: center;
padding: 4px 8px;
/* margin: 0 4px; */
width: -webkit-fill-available;
border-radius: 8px;
&:hover {
background: rgba(116, 127, 141, 0.1);
}
> div {
width: 100%;
}
}
}
> .btn {
width: 100%;
margin-top: 16px;
font-weight: 500;
font-size: 14px;
line-height: 20px;
}
`;
export default function AddMemberModal({ uids = [], cid = null, closeModal }) {
const [
addMembers,
{ isLoading: isAdding, isSuccess },
] = useAddMembersMutation();
const [selects, setSelects] = useState([]);
const { channel, contactIds, contactData } = useSelector((store) => {
return {
channel: store.channels.byId[cid],
contactIds: store.contacts.ids,
contactData: store.contacts.byId,
};
});
useEffect(() => {
if (isSuccess) {
toast.success("Add members successfully!");
closeModal();
}
}, [isSuccess]);
const handleAddMembers = () => {
addMembers({ id: cid, members: selects });
};
// const { input, updateInput, contacts } = useFilteredUsers();
const toggleCheckMember = ({ currentTarget }) => {
const { uid } = currentTarget.dataset;
if (selects.includes(+uid)) {
setSelects((prevs) => {
return prevs.filter((id) => id != uid);
});
} else {
setSelects([...selects, +uid]);
}
};
// const handleFilterInput = (evt) => {
// updateInput(evt.target.value);
// };
if (!channel) return null;
console.log("selects", selects);
return (
<Modal>
<Styled>
<div className="head">
Add friends to #{channel.name}{" "}
<img onClick={closeModal} className="close" src={closeIcon} />
</div>
<div className="filter">
<ul className="selects">
{selects.map((uid) => {
return (
<li className="select" key={uid}>
{contactData[uid].name}
<img
data-uid={uid}
onClick={toggleCheckMember}
className="close"
src={closeIcon}
/>
</li>
);
})}
</ul>
{/* <input
type="text"
className="input"
value={input}
onChange={handleFilterInput}
/> */}
</div>
<ul className="users">
{contactIds.map((uid) => {
const added = uids.includes(uid);
return (
<li
key={uid}
data-uid={uid}
className="user"
onClick={added ? null : toggleCheckMember}
>
<StyledCheckbox
disabled={added}
readOnly
checked={added || selects.includes(uid)}
name="cb"
id="cb"
/>
<Contact uid={uid} interactive={false} />
</li>
);
})}
</ul>
<Button
disabled={selects.length == 0 || isAdding}
className="btn main"
onClick={handleAddMembers}
>
{isAdding ? `Adding` : "Add"} to #{channel.name}
</Button>
</Styled>
</Modal>
);
}
+97 -66
View File
@@ -1,37 +1,48 @@
import { useEffect, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
// import dayjs from "dayjs";
import { useSelector } from "react-redux";
import useChatScroll from "../../../common/hook/useChatScroll";
import Message from "../../../common/component/Message";
import ChannelIcon from "../../../common/component/ChannelIcon";
import Send from "../../../common/component/Send";
// import { readMessage } from "../../../app/slices/message";
import Contact from "../../../common/component/Contact";
import Layout from "../Layout";
import { renderMessageFragment } from "../utils";
import alertIcon from "../../../assets/icons/alert.svg?url";
import peopleIcon from "../../../assets/icons/people.svg?url";
import pinIcon from "../../../assets/icons/pin.svg?url";
import addIcon from "../../../assets/icons/add.svg?url";
import {
StyledNotification,
// StyledNotification,
StyledContacts,
StyledChannelChat,
StyledHeader,
} from "./styled";
import AddMemberModal from "./AddMemberModal";
export default function ChannelChat({ cid = "", dropFiles = [] }) {
// const containerRef = useRef(null);
const [membersVisible, setMembersVisible] = useState(true);
const [addMemberModalVisible, setAddMemberModalVisible] = useState(false);
const [dragFiles, setDragFiles] = useState([]);
// const dispatch = useDispatch();
const { msgIds, userIds, data } = useSelector((store) => {
const { msgIds, userIds, data, messageData } = useSelector((store) => {
return {
msgIds: store.channelMessage[cid] || [],
userIds: store.contacts.ids,
data: store.channels.byId[cid],
data: store.channels.byId[cid] || {},
messageData: store.message || {},
};
});
const ref = useChatScroll(msgIds);
// const handleClearUnreads = () => {
// dispatch(readMessage(msgIds));
// };
const toggleMembersVisible = () => {
setMembersVisible((prev) => !prev);
};
const toggleAddVisible = () => {
setAddMemberModalVisible((prev) => !prev);
};
useEffect(() => {
if (dropFiles.length) {
setDragFiles(dropFiles);
@@ -41,65 +52,84 @@ export default function ChannelChat({ cid = "", dropFiles = [] }) {
const memberIds = members.length == 0 ? userIds : members;
console.log("channel message list", msgIds);
return (
<Layout
setDragFiles={setDragFiles}
// ref={containerRef}
header={
<StyledHeader>
<div className="txt">
<ChannelIcon personal={!is_public} />
<span className="title">{name}</span>
<span className="desc">{description}</span>
<>
{addMemberModalVisible && (
<AddMemberModal
cid={cid}
uids={members}
closeModal={toggleAddVisible}
/>
)}
<Layout
setDragFiles={setDragFiles}
// ref={containerRef}
header={
<StyledHeader>
<div className="txt">
<ChannelIcon personal={!is_public} />
<span className="title">{name}</span>
<span className="desc">{description}</span>
</div>
<ul className="opts">
<li className="opt">
<img src={alertIcon} alt="opt icon" />
</li>
<li className="opt">
<img src={pinIcon} alt="opt icon" />
</li>
<li className="opt" onClick={toggleMembersVisible}>
<img src={peopleIcon} alt="opt icon" />
</li>
</ul>
</StyledHeader>
}
contacts={
membersVisible ? (
<>
<StyledContacts>
{!is_public && (
<div className="add" onClick={toggleAddVisible}>
<img className="icon" src={addIcon} />
<div className="txt">Add members</div>
</div>
)}
{memberIds.map((uid) => {
return <Contact key={uid} uid={uid} popover />;
})}
</StyledContacts>
</>
) : null
}
>
<StyledChannelChat>
<div className="wrapper" ref={ref}>
<div className="info">
<h2 className="title">Welcome to #{name} !</h2>
<p className="desc">This is the start of the #{name} channel. </p>
{/* <button className="edit">Edit Channel</button> */}
</div>
<div className="chat">
{[...msgIds]
.sort((a, b) => {
return Number(a) - Number(b);
})
.map((mid, idx) => {
const prev = idx == 0 ? null : messageData[msgIds[idx - 1]];
const curr = messageData[mid];
return renderMessageFragment({
prev,
curr,
contextId: cid,
context: "channel",
});
})}
</div>
</div>
<ul className="opts">
<li className="opt">
<img
src="https://static.nicegoodthings.com/project/rustchat/icon.alert.svg"
alt="opt icon"
/>
</li>
<li className="opt">
<img
src="https://static.nicegoodthings.com/project/rustchat/icon.pin.svg"
alt="opt icon"
/>
</li>
<li className="opt">
<img
src="https://static.nicegoodthings.com/project/rustchat/icon.people.svg"
alt="opt icon"
/>
</li>
</ul>
</StyledHeader>
}
contacts={
<StyledContacts>
{memberIds.map((uid) => {
return <Contact key={uid} uid={uid} popover />;
})}
</StyledContacts>
}
>
<StyledChannelChat>
<div className="wrapper" ref={ref}>
<div className="info">
<h2 className="title">Welcome to #{name} !</h2>
<p className="desc">This is the start of the #{name} channel. </p>
{/* <button className="edit">Edit Channel</button> */}
</div>
<div className="chat">
{msgIds.map((mid, idx) => {
// if (!msg) return null;
return <Message contextId={cid} mid={mid} key={idx} />;
})}
</div>
</div>
<Send dragFiles={dragFiles} id={cid} type="channel" name={name} />
<div className="placeholder"></div>
</StyledChannelChat>
{/* {unreads != 0 && (
<Send dragFiles={dragFiles} id={cid} type="channel" name={name} />
<div className="placeholder"></div>
</StyledChannelChat>
{/* {unreads != 0 && (
<StyledNotification>
<div className="content">
{unreads} new messages
@@ -112,6 +142,7 @@ export default function ChannelChat({ cid = "", dropFiles = [] }) {
</button>
</StyledNotification>
)} */}
</Layout>
</Layout>
</>
);
}
+23
View File
@@ -72,6 +72,29 @@ export const StyledContacts = styled.div`
overflow-y: scroll;
background: #f5f6f7;
padding: 8px;
> .add {
cursor: pointer;
display: flex;
align-items: center;
justify-content: flex-start;
gap: 4px;
padding: 10px;
border-radius: 8px;
user-select: none;
&:hover {
background: rgba(116, 127, 141, 0.1);
}
.icon {
width: 24px;
height: 24px;
}
.txt {
font-weight: 600;
font-size: 14px;
line-height: 20px;
color: #52525b;
}
}
`;
export const StyledChannelChat = styled.article`
position: relative;
+1 -1
View File
@@ -7,7 +7,7 @@ import useContextMenu from "../../common/hook/useContextMenu";
import ContextMenu from "../../common/component/ContextMenu";
import { toggleChannelSetting } from "../../app/slices/ui";
import ChannelIcon from "../../common/component/ChannelIcon";
import getUnreadCount from "./getUnreadCount";
import { getUnreadCount } from "./utils";
const NavItem = ({ id, setFiles, contextMenuEventHandler }) => {
const dispatch = useDispatch();
const navigate = useNavigate();
+28 -27
View File
@@ -1,21 +1,24 @@
import { useState, useEffect } from "react";
import { useSelector } from "react-redux";
import addIcon from "../../../assets/icons/add.person.svg?url";
import callIcon from "../../../assets/icons/call.svg?url";
import videoIcon from "../../../assets/icons/video.svg?url";
import useChatScroll from "../../../common/hook/useChatScroll";
import Message from "../../../common/component/Message";
import Send from "../../../common/component/Send";
import Contact from "../../../common/component/Contact";
import Layout from "../Layout";
import { StyledHeader, StyledDMChat } from "./styled";
import { renderMessageFragment } from "../utils";
export default function DMChat({ uid = "", dropFiles = [] }) {
console.log("dm files", dropFiles);
const [dragFiles, setDragFiles] = useState([]);
// const [mids, setMids] = useState([]);
const { msgIds, currUser } = useSelector((store) => {
const { msgIds, currUser, messageData, readUsers } = useSelector((store) => {
return {
currUser: store.contacts.byId[uid],
msgIds: store.userMessage.byId[uid] || [],
messageData: store.message,
readUsers: store.footprint.readUser || {},
};
});
const ref = useChatScroll(msgIds);
@@ -30,6 +33,7 @@ export default function DMChat({ uid = "", dropFiles = [] }) {
if (!currUser) return null;
// console.log("user msgs", msgs);
const readIndex = readUsers[uid] || 0;
return (
<Layout
setDragFiles={setDragFiles}
@@ -38,28 +42,13 @@ export default function DMChat({ uid = "", dropFiles = [] }) {
<Contact interactive={false} uid={currUser.uid} />
<ul className="opts">
<li className="opt">
<img
src="https://static.nicegoodthings.com/project/rustchat/icon.call.svg"
alt="opt icon"
/>
<img src={callIcon} alt="opt icon" />
</li>
<li className="opt">
<img
src="https://static.nicegoodthings.com/project/rustchat/icon.video.svg"
alt="opt icon"
/>
<img src={videoIcon} alt="opt icon" />
</li>
<li className="opt">
<img
src="https://static.nicegoodthings.com/project/rustchat/icon.people.add.svg"
alt="opt icon"
/>
</li>
<li className="opt">
<img
src="https://static.nicegoodthings.com/project/rustchat/icon.mark.read.svg"
alt="opt icon"
/>
<img src={addIcon} alt="opt icon" />
</li>
</ul>
</StyledHeader>
@@ -67,11 +56,23 @@ export default function DMChat({ uid = "", dropFiles = [] }) {
>
<StyledDMChat>
<div className="chat" ref={ref}>
{msgIds.map((mid) => {
// if (!msg) return null;
// console.log("user msg", msg);
return <Message mid={mid} key={mid} contextId={uid} />;
})}
{[...msgIds]
.sort((a, b) => {
return Number(a) - Number(b);
})
.map((mid, idx) => {
const curr = messageData[mid];
const self = curr.from_uid == currUser.uid;
const read = self ? true : mid <= readIndex ? true : false;
const prev = idx == 0 ? null : messageData[msgIds[idx - 1]];
return renderMessageFragment({
prev,
curr,
contextId: uid,
read,
context: "user",
});
})}
</div>
</StyledDMChat>
<div className="placeholder"></div>
+7 -2
View File
@@ -5,6 +5,7 @@ import relativeTime from "dayjs/plugin/relativeTime";
import { useDrop } from "react-dnd";
import { NativeTypes } from "react-dnd-html5-backend";
import { useSelector } from "react-redux";
import { renderPreviewMessage } from "./utils";
import Contact from "../../common/component/Contact";
dayjs.extend(relativeTime);
const NavItem = ({ uid, mid, unreads, setFiles }) => {
@@ -48,8 +49,12 @@ const NavItem = ({ uid, mid, unreads, setFiles }) => {
</div>
<div className="down">
{currMsg && <div className="msg">{currMsg.content}</div>}
{unreads > 0 && <i className="badge">{unreads}</i>}
{renderPreviewMessage(currMsg)}
{unreads > 0 && (
<i className={`badge ${unreads > 99 ? "dot" : ""}`}>
{unreads > 99 ? null : unreads}
</i>
)}
</div>
</div>
</NavLink>
+1 -1
View File
@@ -14,7 +14,7 @@ const StyledWrapper = styled.article`
padding: 0 20px;
box-shadow: 0px 1px 0px rgba(0, 0, 0, 0.1);
}
.main {
> .main {
height: calc(100vh - 56px);
width: 100%;
display: flex;
-24
View File
@@ -1,24 +0,0 @@
// import React from "react";
import { VariableSizeList as List } from "react-window";
import Message from "../../common/component/Message";
export default function MessageList({ messages = [] }) {
const Row = ({ index, style }) => (
<div style={style}>
<Message {...messages[index]} />
</div>
);
const getItemSize = (index) =>
messages[index].content_type.startsWith("image") ? 150 : 56;
return (
<List
height={window.innerHeight - 56}
width={"100%"}
itemCount={messages.length}
itemSize={getItemSize}
// estimatedItemSize={56}
>
{Row}
</List>
);
}
-12
View File
@@ -1,12 +0,0 @@
const getUnreadCount = (mids, messageData) => {
if (!mids || !messageData) return 0;
let unreads = 0;
mids.forEach((id) => {
if (!messageData[id].read) {
unreads++;
}
});
return unreads;
};
export default getUnreadCount;
+1 -5
View File
@@ -16,7 +16,7 @@ import ChannelList from "./ChannelList";
import ContactsModal from "../../common/component/ContactsModal";
import ChannelModal from "../../common/component/ChannelModal";
import DMList from "./DMList";
import getUnreadCount from "./getUnreadCount";
import { getUnreadCount } from "./utils";
export default function ChatPage() {
const [channelDropFiles, setChannelDropFiles] = useState([]);
@@ -37,10 +37,6 @@ export default function ChatPage() {
const toggleChannelModalVisible = () => {
setChannelModalVisible((prev) => !prev);
};
// const getUnreadCount = (gid) => {
// return Object.values(ChannelMsgData[gid] || {}).filter((m) => m.read)
// .length;
// };
const handleToggleExpand = (evt) => {
const { currentTarget } = evt;
const listEle = currentTarget.parentElement.parentElement;
+85
View File
@@ -0,0 +1,85 @@
import React from "react";
import dayjs from "dayjs";
import { ContentTypes } from "../../app/config";
import Divider from "../../common/component/Divider";
import Message from "../../common/component/Message";
export const getUnreadCount = (mids, messageData) => {
if (!mids || !messageData) return 0;
let unreads = 0;
mids.forEach((id) => {
if (messageData[id] && !messageData[id].read) {
unreads++;
}
});
return unreads;
};
export const renderPreviewMessage = (message = null) => {
if (!message) return null;
const { content_type, content } = message;
let res = null;
switch (content_type) {
case ContentTypes.text:
{
res = <div className="msg">{content}</div>;
}
break;
case ContentTypes.imageJPG:
case ContentTypes.image:
{
res = <div className="msg">[image]</div>;
}
break;
case ContentTypes.markdown:
{
res = <div className="msg">[markdown]</div>;
}
break;
case ContentTypes.file:
{
res = <div className="msg">[file]</div>;
}
break;
default:
break;
}
return res;
};
export const renderMessageFragment = ({
prev = null,
curr = null,
contextId = 0,
read = true,
context = "user",
}) => {
if (!curr) return null;
let { created_at, mid } = curr;
let divider = null;
let time = dayjs(created_at).format("YYYY/MM/DD");
if (!prev) {
divider = time;
} else {
let { created_at: prev_created_at } = prev;
if (!dayjs(prev_created_at).isSame(created_at, "day")) {
divider = time;
}
}
return (
<React.Fragment key={mid}>
{divider && <Divider content={divider}></Divider>}
<Message
read={read}
context={context}
mid={mid}
key={mid}
contextId={contextId}
/>
</React.Fragment>
);
};
export default getUnreadCount;
+17 -9
View File
@@ -1,25 +1,29 @@
import { useEffect, useState } from "react";
import { useDispatch } from "react-redux";
import { useDispatch, useSelector } from "react-redux";
import { useNavigate } from "react-router-dom";
import initCache, { useRehydrate } from "../../app/cache";
import { useLazyGetContactsQuery } from "../../app/services/contact";
import { useLazyInitStreamingQuery } from "../../app/services/streaming";
// import { useLazyInitStreamingQuery } from "../../app/services/streaming";
import { resetAuthData, setUid } from "../../app/slices/auth.data";
import { fullfillContacts } from "../../app/slices/contacts";
// import { useGetChannelsQuery } from "../../app/services/channel";
import { useLazyGetServerQuery } from "../../app/services/server";
import useStreaming from "../../common/hook/useStreaming";
import { KEY_UID } from "../../app/config";
// pollingInterval: 0,
// const querySetting = {
// refetchOnMountOrArgChange: true,
// };
let request = null;
export default function usePreload() {
const { rehydrate, rehydrated } = useRehydrate();
const [initStreaming, { isLoading: streaming }] = useLazyInitStreamingQuery();
// const [initStreaming, { isLoading: streaming }] = useLazyInitStreamingQuery();
const [checked, setChecked] = useState(false);
const dispatch = useDispatch();
const navigate = useNavigate();
const store = useSelector((store) => store);
const { startStreaming, streaming, initializing } = useStreaming();
const [
getContacts,
{
@@ -41,6 +45,11 @@ export default function usePreload() {
useEffect(() => {
initCache();
rehydrate();
return () => {
if (request) {
request.abort();
}
};
}, []);
useEffect(() => {
@@ -51,10 +60,10 @@ export default function usePreload() {
}
}, [rehydrated]);
useEffect(() => {
if (checked && rehydrated) {
initStreaming({}, false);
if (checked && rehydrated && !initializing && !streaming) {
request = startStreaming(store);
}
}, [checked, rehydrated]);
}, [checked, rehydrated, store, streaming, initializing]);
useEffect(() => {
const local_uid = localStorage.getItem(KEY_UID);
@@ -76,10 +85,9 @@ export default function usePreload() {
}
}
}, [contacts]);
console.log("loading", contactsLoading, serverLoading, !checked, streaming);
// console.log("loading", contactsLoading, serverLoading, !checked);
return {
loading:
contactsLoading || serverLoading || !checked || !rehydrated || streaming,
loading: contactsLoading || serverLoading || !checked || !rehydrated,
error: contactsError && serverError,
success: contactsSuccess && serverSuccess,
data: {
+14 -5
View File
@@ -1,9 +1,6 @@
// import { useState } from "react";
import { useEffect } from "react";
import { Route, Routes, HashRouter } from "react-router-dom";
import { Provider } from "react-redux";
// import { persistStore } from 'redux-persist';
// import { PersistGate } from 'redux-persist/integration/react';
import { Provider, useSelector } from "react-redux";
// import Welcome from './Welcome'
import NotFoundPage from "./404";
import LoginPage from "./login";
@@ -14,8 +11,20 @@ import RequireAuth from "../common/component/RequireAuth";
import store from "../app/store";
import InvitePage from "./invite";
import toast from "react-hot-toast";
const PageRoutes = () => {
const { online } = useSelector((store) => store.ui);
// 掉线检测
useEffect(() => {
let toastId = 0;
if (!online) {
toast.error("Network Offline!", { duration: Infinity });
} else {
toast.dismiss(toastId);
}
}, [online]);
return (
<HashRouter>
<Routes>
+1687 -711
View File
File diff suppressed because it is too large Load Diff