refactor: lots updates
This commit is contained in:
+481
-459
@@ -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
@@ -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
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]}`;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
:root{
|
||||
/* border radius */
|
||||
--br:8px
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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} />
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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;
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user