merge from local

This commit is contained in:
SrGooglo 2024-03-15 20:41:09 +00:00
parent 65d75ef939
commit a1bb256f08
124 changed files with 2951 additions and 1903 deletions

View File

@ -9,7 +9,7 @@
3006 -> sync 3006 -> sync
3007 -> ems (External Messaging Service) 3007 -> ems (External Messaging Service)
3008 -> users 3008 -> users
3009 -> unallocated 3009 -> notifications
3010 -> unallocated 3010 -> unallocated
3011 -> unallocated 3011 -> unallocated
3012 -> unallocated 3012 -> unallocated

View File

@ -1,5 +1,4 @@
const path = require("path") const path = require("path")
const { builtinModules } = require("module")
const aliases = { const aliases = {
"node:buffer": "buffer", "node:buffer": "buffer",
@ -19,7 +18,7 @@ const aliases = {
hooks: path.join(__dirname, "src/hooks"), hooks: path.join(__dirname, "src/hooks"),
classes: path.join(__dirname, "src/classes"), classes: path.join(__dirname, "src/classes"),
"comty.js": path.join(__dirname, "../../", "comty.js", "src"), "comty.js": path.join(__dirname, "../../", "comty.js", "src"),
models: path.join(__dirname, "../comty.js/src/models"), models: path.join(__dirname, "../../", "comty.js/src/models"),
} }
module.exports = (config = {}) => { module.exports = (config = {}) => {
@ -30,11 +29,6 @@ module.exports = (config = {}) => {
config.server = {} config.server = {}
} }
// config.define = {
// "global.Uint8Array": "Uint8Array",
// "process.env.NODE_DEBUG": false,
// }
config.resolve.alias = aliases config.resolve.alias = aliases
config.server.port = process.env.listenPort ?? 8000 config.server.port = process.env.listenPort ?? 8000
config.server.host = "0.0.0.0" config.server.host = "0.0.0.0"
@ -56,25 +50,5 @@ module.exports = (config = {}) => {
target: "esnext" target: "esnext"
} }
// config.build = {
// sourcemap: "inline",
// target: `node16`,
// outDir: "dist",
// assetsDir: ".",
// minify: process.env.MODE !== "development",
// rollupOptions: {
// external: [
// "electron",
// "electron-devtools-installer",
// ...builtinModules.flatMap(p => [p, `node:16`]),
// ],
// output: {
// entryFileNames: "[name].js",
// },
// },
// emptyOutDir: true,
// brotliSize: false,
// }
return config return config
} }

View File

@ -1,4 +1,6 @@
{ {
"low_performance_mode": false,
"transcode_video_browser": false,
"forceMobileMode": false, "forceMobileMode": false,
"ui.effects": true, "ui.effects": true,
"ui.general_volume": 50, "ui.general_volume": 50,

View File

@ -82,6 +82,12 @@ export default [
useLayout: "minimal", useLayout: "minimal",
public: true public: true
}, },
{
path: "/marketplace/*",
useLayout: "default",
centeredContent: true,
extendedContent: true,
},
// THIS MUST BE THE LAST ROUTE // THIS MUST BE THE LAST ROUTE
{ {
path: "/", path: "/",

View File

@ -7,6 +7,8 @@ import { Icons } from "components/Icons"
import config from "config" import config from "config"
import LatencyIndicator from "components/PerformanceIndicators/latency"
import "./index.less" import "./index.less"
const connectionsTooltipStrings = { const connectionsTooltipStrings = {
@ -48,9 +50,7 @@ export default {
const [serverManifest, setServerManifest] = React.useState(null) const [serverManifest, setServerManifest] = React.useState(null)
const [serverOrigin, setServerOrigin] = React.useState(null) const [serverOrigin, setServerOrigin] = React.useState(null)
const [serverHealth, setServerHealth] = React.useState(null)
const [secureConnection, setSecureConnection] = React.useState(false) const [secureConnection, setSecureConnection] = React.useState(false)
const [connectionPing, setConnectionPing] = React.useState({})
const [capInfo, setCapInfo] = React.useState(null) const [capInfo, setCapInfo] = React.useState(null)
const setCapacitorInfo = async () => { const setCapacitorInfo = async () => {
@ -68,7 +68,7 @@ export default {
} }
const checkServerOrigin = async () => { const checkServerOrigin = async () => {
const instance = app.cores.api.instance() const instance = app.cores.api.client()
if (instance) { if (instance) {
setServerOrigin(instance.mainOrigin) setServerOrigin(instance.mainOrigin)
@ -79,29 +79,11 @@ export default {
} }
} }
const measurePing = async () => {
const result = await app.cores.api.measurePing()
console.log(result)
setConnectionPing(result)
}
React.useEffect(() => { React.useEffect(() => {
checkServerVersion() checkServerVersion()
checkServerOrigin() checkServerOrigin()
measurePing()
setCapacitorInfo() setCapacitorInfo()
const measureInterval = setInterval(() => {
measurePing()
}, 3000)
return () => {
clearInterval(measureInterval)
}
}, []) }, [])
return <div className="about_app"> return <div className="about_app">
@ -172,33 +154,13 @@ export default {
width: "100%", width: "100%",
}} }}
> >
<div <LatencyIndicator
style={{ type="http"
display: "flex", />
alignItems: "center",
}}
>
<Icons.MdHttp />
<antd.Tag
color={latencyToColor(connectionPing?.http, "http")}
>
{connectionPing?.http}ms
</antd.Tag>
</div>
<div <LatencyIndicator
style={{ type="ws"
display: "flex", />
alignItems: "center",
}}
>
<Icons.MdSettingsEthernet />
<antd.Tag
color={latencyToColor(connectionPing?.ws, "ws")}
>
{connectionPing?.ws}ms
</antd.Tag>
</div>
</div> </div>
</div> </div>
</div> </div>

View File

@ -62,8 +62,6 @@ const SessionItem = (props) => {
return UAParser(session.client) return UAParser(session.client)
}) })
console.log(session, ua)
return <div return <div
className={classnames( className={classnames(
"security_sessions_list_item_wrapper", "security_sessions_list_item_wrapper",

View File

@ -21,8 +21,6 @@ export default () => {
return null return null
}) })
console.log(response)
if (response) { if (response) {
setSessions(response) setSessions(response)
} }
@ -72,7 +70,6 @@ export default () => {
return `${total} Sessions` return `${total} Sessions`
}} }}
simple simple
/> />
</div> </div>
</div> </div>

View File

@ -72,13 +72,16 @@ export default {
}, },
{ {
id: "low_performance_mode", id: "low_performance_mode",
storaged: true,
group: "general", group: "general",
component: "Switch", component: "Switch",
icon: "MdSlowMotionVideo", icon: "MdSlowMotionVideo",
title: "Low performance mode", title: "Low performance mode",
description: "Enable low performance mode to reduce the memory usage and improve the performance in low-end devices. This will disable some animations and other decorative features.", description: "Enable low performance mode to reduce the memory usage and improve the performance in low-end devices. This will disable some animations and other decorative features.",
emitEvent: "app.lowPerformanceMode", emitEvent: "app.lowPerformanceMode",
props: {
disabled: true
},
storaged: true,
experimental: true, experimental: true,
disabled: true, disabled: true,
}, },
@ -183,6 +186,23 @@ export default {
storaged: true, storaged: true,
mobile: false, mobile: false,
}, },
{
id: "transcode_video_browser",
group: "posts",
component: "Switch",
icon: "MdVideoCameraFront",
title: "Transcode video in browser",
description: "Transcode videos from the application instead of on the servers. This feature may speed up the posting process depending on your computer. This will consume your computer resources.",
dependsOn: {
"low_performance_mode": false,
},
props: {
disabled: true,
},
experimental: true,
storaged: true,
disabled: true,
},
{ {
id: "feed_max_fetch", id: "feed_max_fetch",
title: "Fetch max items", title: "Fetch max items",

View File

@ -28,28 +28,28 @@ export default {
}, },
}, },
{ {
id: "fullName", id: "public_name",
group: "account.basicInfo", group: "account.basicInfo",
component: "Input", component: "Input",
icon: "Edit3", icon: "Edit3",
title: "Name", title: "Name",
description: "Change your public name", description: "Change your public name",
props: { props: {
// set max length maxLength: 120,
"maxLength": 120, showCount: true,
"showCount": true, allowClear: true,
"allowClear": true, placeholder: "Enter your name. e.g. John Doe",
"placeholder": "Enter your name. e.g. John Doe",
}, },
defaultValue: (ctx) => { defaultValue: (ctx) => {
return ctx.userData.fullName return ctx.userData.public_name
}, },
onUpdate: async (value) => { onUpdate: async (value) => {
const result = await UserModel.updateData({ const result = await UserModel.updateData({
fullName: value public_name: value
}) })
if (result) { if (result) {
app.message.success("Public name updated")
return value return value
} }
}, },
@ -67,22 +67,22 @@ export default {
storaged: false, storaged: false,
}, },
{ {
"id": "email", id: "email",
"group": "account.basicInfo", group: "account.basicInfo",
"component": "Input", component: "Input",
"icon": "Mail", icon: "Mail",
"title": "Email", title: "Email",
"description": "Change your email address", description: "Change your email address",
"props": { props: {
"placeholder": "Enter your email address", placeholder: "Enter your email address",
"allowClear": true, allowClear: true,
"showCount": true, showCount: true,
"maxLength": 320, maxLength: 320,
}, },
"defaultValue": (ctx) => { defaultValue: (ctx) => {
return ctx.userData.email return ctx.userData.email
}, },
"onUpdate": async (value) => { onUpdate: async (value) => {
const result = await UserModel.updateData({ const result = await UserModel.updateData({
email: value email: value
}) })
@ -91,22 +91,22 @@ export default {
return value return value
} }
}, },
"debounced": true, debounced: true,
}, },
{ {
"id": "avatar", id: "avatar",
"group": "account.profile", group: "account.profile",
"icon": "Image", icon: "Image",
"title": "Avatar", title: "Avatar",
"description": "Change your avatar (Upload an image or use an URL)", description: "Change your avatar (Upload an image or use an URL)",
"component": loadable(() => import("../components/urlInput")), component: loadable(() => import("../components/urlInput")),
extraActions: [ extraActions: [
UploadButton UploadButton
], ],
"defaultValue": (ctx) => { defaultValue: (ctx) => {
return ctx.userData.avatar return ctx.userData.avatar
}, },
"onUpdate": async (value) => { onUpdate: async (value) => {
const result = await UserModel.updateData({ const result = await UserModel.updateData({
avatar: value avatar: value
}) })
@ -118,19 +118,19 @@ export default {
}, },
}, },
{ {
"id": "cover", id: "cover",
"group": "account.profile", group: "account.profile",
"icon": "Image", icon: "Image",
"title": "Cover", title: "Cover",
"description": "Change your profile cover (Upload an image or use an URL)", description: "Change your profile cover (Upload an image or use an URL)",
"component": loadable(() => import("../components/urlInput")), component: loadable(() => import("../components/urlInput")),
extraActions: [ extraActions: [
UploadButton UploadButton
], ],
"defaultValue": (ctx) => { defaultValue: (ctx) => {
return ctx.userData.cover return ctx.userData.cover
}, },
"onUpdate": async (value) => { onUpdate: async (value) => {
const result = await UserModel.updateData({ const result = await UserModel.updateData({
cover: value cover: value
}) })
@ -142,22 +142,22 @@ export default {
}, },
}, },
{ {
"id": "description", id: "description",
"group": "account.profile", group: "account.profile",
"component": "TextArea", component: "TextArea",
"icon": "Edit3", icon: "Edit3",
"title": "Description", title: "Description",
"description": "Change your description for your profile", description: "Change your description for your profile",
"props": { props: {
"placeholder": "Enter here a description for your profile", placeholder: "Enter here a description for your profile",
"maxLength": 320, maxLength: 320,
"showCount": true, showCount: true,
"allowClear": true allowClear: true
}, },
"defaultValue": (ctx) => { defaultValue: (ctx) => {
return ctx.userData.description return ctx.userData.description
}, },
"onUpdate": async (value) => { onUpdate: async (value) => {
const result = await UserModel.updateData({ const result = await UserModel.updateData({
description: value description: value
}) })
@ -166,8 +166,7 @@ export default {
return value return value
} }
}, },
"debounced": true, debounced: true,
storaged: false,
}, },
{ {
id: "Links", id: "Links",
@ -194,7 +193,6 @@ export default {
return ctx.userData.links ?? [] return ctx.userData.links ?? []
}, },
debounced: true, debounced: true,
storaged: false,
} }
] ]
} }

View File

@ -7,28 +7,31 @@ export default {
group: "basic", group: "basic",
settings: [ settings: [
{ {
"id": "change-password", id: "change-password",
"group": "security.account", group: "security.account",
"title": "Change Password", title: "Change Password",
"description": "Change your password", description: "Change your password",
"icon": "Lock", icon: "Lock",
"component": loadable(() => import("../components/changePassword")), component: loadable(() => import("../components/changePassword")),
}, },
{ {
"id": "two-factor-authentication", id: "auth:mfa",
"group": "security.account", group: "security.account",
"title": "Two-Factor Authentication", title: "2-Factor Authentication",
"description": "Add an extra layer of security to your account", description: "Use your email to validate logins to your account through a numerical code.",
"icon": "MdOutlineSecurity", icon: "IoMdKeypad",
"component": "Switch", component: "Switch",
defaultValue: (ctx) => {
return ctx.baseConfig["auth:mfa"]
}
}, },
{ {
"id": "sessions", id: "sessions",
"group": "security.account", group: "security.account",
"title": "Sessions", title: "Sessions",
"description": "Manage your active sessions", description: "Manage your active sessions",
"icon": "Monitor", icon: "Monitor",
"component": loadable(() => import("../components/sessions")), component: loadable(() => import("../components/sessions")),
} }
] ]
} }

View File

@ -228,6 +228,12 @@ class OwnTags extends React.Component {
</div> </div>
} }
if (!this.state.data) {
return <antd.Empty
description="You don't have any tags yet."
/>
}
return <div className="tap-share-own_tags"> return <div className="tap-share-own_tags">
{ {
this.state.data.length === 0 && <antd.Empty this.state.data.length === 0 && <antd.Empty

View File

@ -28,7 +28,6 @@
"id": "Marketplace", "id": "Marketplace",
"path": "/marketplace", "path": "/marketplace",
"title": "Marketplace", "title": "Marketplace",
"icon": "Box", "icon": "Box"
"disabled": true
} }
] ]

View File

@ -158,6 +158,9 @@ class ComtyApp extends React.Component {
"clearAllOverlays": function () { "clearAllOverlays": function () {
window.app.DrawerController.closeAll() window.app.DrawerController.closeAll()
}, },
"app.clearInternalStorage": function () {
app.clearInternalStorage()
},
} }
static publicMethods = { static publicMethods = {
@ -248,8 +251,11 @@ class ComtyApp extends React.Component {
/>) />)
}, },
openPostCreator: () => { openPostCreator: (params) => {
app.layout.modal.open("post_creator", (props) => <PostCreator {...props} />, { app.layout.modal.open("post_creator", (props) => <PostCreator
{...props}
{...params}
/>, {
framed: false framed: false
}) })
} }

View File

@ -1,90 +0,0 @@
import React from "react"
import { Icons } from "components/Icons"
import * as antd from "antd"
import { getBase64 } from "utils"
export default class ImageUploader extends React.Component {
state = {
previewVisible: false,
previewImage: "",
previewTitle: "",
fileList: [],
urlList: [],
}
api = window.app.cores.api.withEndpoints()
handleChange = ({ fileList }) => {
this.setState({ fileList })
if (typeof this.props.onChange === "function") {
this.props.onChange(fileList)
}
}
handleCancel = () => this.setState({ previewVisible: false })
handlePreview = async file => {
if (!file.url && !file.preview) {
file.preview = await getBase64(file.originFileObj)
}
this.setState({
previewImage: file.url || file.preview,
previewVisible: true,
previewTitle: file.name || file.url.substring(file.url.lastIndexOf("/") + 1),
})
}
handleUploadRequest = async (req) => {
if (typeof this.props.onUpload === "function") {
this.props.onUpload(req)
} else {
const payloadData = new FormData()
payloadData.append(req.file.name, req.file)
const result = await this.api.post.upload(payloadData).catch(() => {
req.onError("Error uploading image")
return false
})
if (result) {
req.onSuccess()
await this.setState({ urlList: [...this.state.urlList, ...result.urls] })
}
if (typeof this.props.onUploadDone === "function") {
await this.props.onUploadDone(this.state.urlList)
}
return result.urls
}
}
render() {
const uploadButton = (<div>
<Icons.Plus />
<div style={{ marginTop: 8 }}>Upload</div>
</div>)
return <div>
<antd.Upload
listType="picture-card"
fileList={this.state.fileList}
onPreview={this.handlePreview}
onChange={this.handleChange}
customRequest={this.handleUploadRequest}
>
{this.state.fileList.length >= 8 ? null : uploadButton}
</antd.Upload>
<antd.Modal
visible={this.state.previewVisible}
title={this.state.previewTitle}
footer={null}
onCancel={this.handleCancel}
>
<img style={{ width: "100%" }} src={this.state.previewImage} />
</antd.Modal>
</div>
}
}

View File

@ -42,7 +42,7 @@ export default class LiveChat extends React.Component {
timelineRef = React.createRef() timelineRef = React.createRef()
socket = app.cores.api.instance().wsInstances.chat socket = app.cores.api.instance().sockets.chat
roomEvents = { roomEvents = {
"room:recive:message": (message) => { "room:recive:message": (message) => {

View File

@ -50,21 +50,14 @@ export default React.forwardRef((props, ref) => {
> >
{children} {children}
<div style={{ clear: "both" }} /> <lb style={{ clear: "both" }} />
<div <lb
id="bottom" id="bottom"
className="bottom" className="bottom"
style={{ display: hasMore ? "block" : "none" }} style={{ display: hasMore ? "block" : "none" }}
> >
{loadingComponent && React.createElement(loadingComponent)} {loadingComponent && React.createElement(loadingComponent)}
</div> </lb>
{/* <div
className="no-result"
style={{ display: hasMore ? "none" : "block" }}
>
{noResultComponent ? React.createElement(noResultComponent) : "No more result"}
</div> */}
</div> </div>
}) })

View File

@ -200,7 +200,8 @@ export default class Login extends React.Component {
} }
this.setState({ this.setState({
phase: to phase: to,
mfa_required: null,
}) })
} }

View File

@ -0,0 +1,91 @@
import React from "react"
import * as antd from "antd"
import { createIconRender } from "components/Icons"
import "./index.less"
const latencyToColor = (latency, type) => {
switch (type) {
case "http": {
if (latency < 200) {
return "green"
}
if (latency < 500) {
return "orange"
}
return "red"
}
case "ws": {
if (latency < 80) {
return "green"
}
if (latency < 120) {
return "orange"
}
return "red"
}
}
}
const TypesDecorator = {
http: {
label: "HTTP",
icon: "MdHttp",
},
ws: {
label: "WS",
icon: "MdSettingsEthernet",
}
}
const LatencyIndicator = (props) => {
const { type } = props
const [latencyMs, setLatencyMs] = React.useState("0")
const decorator = TypesDecorator[type]
if (!decorator) {
return null
}
function calculateLatency() {
if (typeof props.calculateLatency === "function") {
return setLatencyMs(props.calculateLatency())
}
app.cores.api.measurePing({
select: [type]
}).then((result) => {
setLatencyMs(result)
})
}
React.useEffect(() => {
calculateLatency()
const interval = setInterval(() => {
calculateLatency()
}, props.interval ?? 3000)
return () => clearInterval(interval)
}, [])
return <div
className="latencyIndicator"
>
{
decorator.icon && createIconRender(decorator.icon)
}
{
!decorator.icon && (decorator.label ?? "Latency")
}
<antd.Tag
color={latencyToColor(latencyMs, type)}
>
{latencyMs}ms
</antd.Tag>
</div>
}
export default LatencyIndicator

View File

@ -0,0 +1,13 @@
.latencyIndicator {
display: inline-flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 7px;
svg {
margin: 0;
}
}

View File

@ -4,7 +4,7 @@ import { Icons } from "components/Icons"
import SaveButton from "./saveButton" import SaveButton from "./saveButton"
import LikeButton from "./likeButton" import LikeButton from "./likeButton"
import CommentsButton from "./commentsButton" import RepliesButton from "./replyButton"
import "./index.less" import "./index.less"
@ -32,7 +32,7 @@ const MoreActionsItems = [
{ {
key: "onClickRepost", key: "onClickRepost",
label: <> label: <>
<Icons.Repeat /> <Icons.MdCallSplit />
<span>Repost</span> <span>Repost</span>
</>, </>,
}, },
@ -61,7 +61,7 @@ export default (props) => {
const { const {
onClickLike, onClickLike,
onClickSave, onClickSave,
onClickComments, onClickReply,
} = props.actions ?? {} } = props.actions ?? {}
const genItems = () => { const genItems = () => {
@ -95,10 +95,10 @@ export default (props) => {
onClick={onClickSave} onClick={onClickSave}
/> />
</div> </div>
<div className="action" id="comments"> <div className="action" id="replies">
<CommentsButton <RepliesButton
count={props.commentsCount} count={props.repliesCount}
onClick={onClickComments} onClick={onClickReply}
/> />
</div> </div>
<div className="action" id="more"> <div className="action" id="more">

View File

@ -6,16 +6,16 @@ import "./index.less"
export default (props) => { export default (props) => {
return <div return <div
className="comments_button" className="reply_button"
> >
<Button <Button
type="ghost" type="ghost"
shape="circle" shape="circle"
onClick={props.onClick} onClick={props.onClick}
icon={<Icons.MessageCircle />} icon={<Icons.Repeat />}
/> />
{ {
props.count > 0 && <span className="comments_count">{props.count}</span> props.count > 0 && <span className="replies_count">{props.count}</span>
} }
</div> </div>
} }

View File

@ -1,10 +1,10 @@
.comments_button { .reply_button {
display: inline-flex; display: inline-flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
.comments_count { .replies_count {
font-size: 0.8rem; font-size: 0.8rem;
} }
} }

View File

@ -1,13 +1,29 @@
import React from "react" import React from "react"
import { DateTime } from "luxon" import { DateTime } from "luxon"
import { Tag } from "antd" import { Tag, Skeleton } from "antd"
import { Image } from "components" import { Image } from "components"
import { Icons } from "components/Icons" import { Icons } from "components/Icons"
import PostLink from "components/PostLink"
import PostService from "models/post"
import "./index.less" import "./index.less"
export default (props) => { const PostReplieView = (props) => {
const { data } = props
if (!data) {
return null
}
return <div>
@{data.user.username}
{data.message}
</div>
}
const PostCardHeader = (props) => {
const [timeAgo, setTimeAgo] = React.useState(0) const [timeAgo, setTimeAgo] = React.useState(0)
const goToProfile = () => { const goToProfile = () => {
@ -17,7 +33,12 @@ export default (props) => {
const updateTimeAgo = () => { const updateTimeAgo = () => {
let createdAt = props.postData.timestamp ?? props.postData.created_at ?? "" let createdAt = props.postData.timestamp ?? props.postData.created_at ?? ""
const timeAgo = DateTime.fromISO(createdAt, { locale: app.cores.settings.get("language") }).toRelative() const timeAgo = DateTime.fromISO(
createdAt,
{
locale: app.cores.settings.get("language")
}
).toRelative()
setTimeAgo(timeAgo) setTimeAgo(timeAgo)
} }
@ -34,22 +55,41 @@ export default (props) => {
} }
}, []) }, [])
return <div className="post_header" onDoubleClick={props.onDoubleClick}> return <div className="post-header" onDoubleClick={props.onDoubleClick}>
<div className="user"> {
<div className="avatar"> !props.disableReplyTag && props.postData.reply_to && <div
className="post-header-replied_to"
>
<Icons.Repeat />
<span>
Replied to
</span>
<PostReplieView
data={props.postData.reply_to_data}
/>
</div>
}
<div className="post-header-user">
<div className="post-header-user-avatar">
<Image <Image
alt="Avatar" alt="Avatar"
src={props.postData.user?.avatar} src={props.postData.user?.avatar}
/> />
</div> </div>
<div className="info">
<div className="post-header-user-info">
<h1 onClick={goToProfile}> <h1 onClick={goToProfile}>
{ {
props.postData.user?.fullName ?? `${props.postData.user?.username}` props.postData.user?.public_name ?? `${props.postData.user?.username}`
} }
{ {
props.postData.user?.verified && <Icons.verifiedBadge /> props.postData.user?.verified && <Icons.verifiedBadge />
} }
{ {
props.postData.flags?.includes("nsfw") && <Tag props.postData.flags?.includes("nsfw") && <Tag
color="volcano" color="volcano"
@ -59,10 +99,12 @@ export default (props) => {
} }
</h1> </h1>
<span className="timeago"> <span className="post-header-user-info-timeago">
{timeAgo} {timeAgo}
</span> </span>
</div> </div>
</div> </div>
</div> </div>
} }
export default PostCardHeader

View File

@ -1,9 +1,26 @@
.post_header { .post-header {
display: flex; display: flex;
flex-direction: row; flex-direction: column;
justify-content: space-between;
.user { gap: 10px;
.post-header-replied_to {
display: flex;
flex-direction: row;
align-items: center;
gap: 7px;
svg {
color: var(--text-color);
margin: 0 !important;
}
line-height: 1.5rem;
}
.post-header-user {
display: inline-flex; display: inline-flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
@ -17,7 +34,7 @@
margin-left: 6px; margin-left: 6px;
} }
.avatar { .post-header-user-avatar {
width: 40px; width: 40px;
height: 40px; height: 40px;
@ -30,7 +47,7 @@
} }
} }
.info { .post-header-user-info {
display: inline-flex; display: inline-flex;
flex-direction: column; flex-direction: column;
@ -51,7 +68,7 @@
color: var(--background-color-contrast); color: var(--background-color-contrast);
} }
.timeago { .post-header-user-info-timeago {
font-weight: 400; font-weight: 400;
font-size: 0.7rem; font-size: 0.7rem;

View File

@ -1,11 +1,8 @@
import React from "react" import React from "react"
import * as antd from "antd"
import classnames from "classnames" import classnames from "classnames"
import Plyr from "plyr-react" import Plyr from "plyr-react"
import { motion } from "framer-motion"
import { CommentsCard } from "components"
import { Icons } from "components/Icons" import { Icons } from "components/Icons"
import { processString } from "utils" import { processString } from "utils"
import PostHeader from "./components/header" import PostHeader from "./components/header"
@ -13,6 +10,7 @@ import PostActions from "./components/actions"
import PostAttachments from "./components/attachments" import PostAttachments from "./components/attachments"
import "./index.less" import "./index.less"
import { Divider } from "antd"
const messageRegexs = [ const messageRegexs = [
{ {
@ -43,11 +41,14 @@ const messageRegexs = [
export default class PostCard extends React.PureComponent { export default class PostCard extends React.PureComponent {
state = { state = {
data: this.props.data,
countLikes: this.props.data.countLikes ?? 0, countLikes: this.props.data.countLikes ?? 0,
countComments: this.props.data.countComments ?? 0, countReplies: this.props.data.countComments ?? 0,
hasLiked: this.props.data.isLiked ?? false, hasLiked: this.props.data.isLiked ?? false,
hasSaved: this.props.data.isSaved ?? false, hasSaved: this.props.data.isSaved ?? false,
hasReplies: this.props.data.hasReplies ?? false,
open: this.props.defaultOpened ?? false, open: this.props.defaultOpened ?? false,
@ -55,13 +56,28 @@ export default class PostCard extends React.PureComponent {
nsfwAccepted: false, nsfwAccepted: false,
} }
handleDataUpdate = (data) => {
this.setState({
data: data,
})
}
onDoubleClick = async () => {
if (typeof this.props.events.onDoubleClick !== "function") {
console.warn("onDoubleClick event is not a function")
return
}
return await this.props.events.onDoubleClick(this.state.data)
}
onClickDelete = async () => { onClickDelete = async () => {
if (typeof this.props.events.onClickDelete !== "function") { if (typeof this.props.events.onClickDelete !== "function") {
console.warn("onClickDelete event is not a function") console.warn("onClickDelete event is not a function")
return return
} }
return await this.props.events.onClickDelete(this.props.data) return await this.props.events.onClickDelete(this.state.data)
} }
onClickLike = async () => { onClickLike = async () => {
@ -70,7 +86,16 @@ export default class PostCard extends React.PureComponent {
return return
} }
return await this.props.events.onClickLike(this.props.data) const actionResult = await this.props.events.onClickLike(this.state.data)
if (actionResult) {
this.setState({
hasLiked: actionResult.liked,
countLikes: actionResult.count,
})
}
return actionResult
} }
onClickSave = async () => { onClickSave = async () => {
@ -79,7 +104,15 @@ export default class PostCard extends React.PureComponent {
return return
} }
return await this.props.events.onClickSave(this.props.data) const actionResult = await this.props.events.onClickSave(this.state.data)
if (actionResult) {
this.setState({
hasSaved: actionResult.saved,
})
}
return actionResult
} }
onClickEdit = async () => { onClickEdit = async () => {
@ -88,57 +121,26 @@ export default class PostCard extends React.PureComponent {
return return
} }
return await this.props.events.onClickEdit(this.props.data) return await this.props.events.onClickEdit(this.state.data)
} }
onDoubleClick = async () => { onClickReply = async () => {
this.handleOpen() if (typeof this.props.events.onClickReply !== "function") {
} console.warn("onClickReply event is not a function")
return
onClickComments = async () => {
this.handleOpen()
}
handleOpen = (to) => {
if (typeof to === "undefined") {
to = !this.state.open
} }
if (typeof this.props.events?.ontoggleOpen === "function") { return await this.props.events.onClickReply(this.state.data)
this.props.events?.ontoggleOpen(to, this.props.data)
}
this.setState({
open: to,
})
//app.controls.openPostViewer(this.props.data)
} }
onLikesUpdate = (data) => { componentDidUpdate = (prevProps) => {
console.log("onLikesUpdate", data) if (prevProps.data !== this.props.data) {
if (data.to) {
this.setState({ this.setState({
countLikes: this.state.countLikes + 1, data: this.props.data,
})
} else {
this.setState({
countLikes: this.state.countLikes - 1,
}) })
} }
} }
componentDidMount = async () => {
// first listen to post changes
app.cores.api.listenEvent(`post.${this.props.data._id}.likes.update`, this.onLikesUpdate)
}
componentWillUnmount = () => {
// remove the listener
app.cores.api.unlistenEvent(`post.${this.props.data._id}.likes.update`, this.onLikesUpdate)
}
componentDidCatch = (error, info) => { componentDidCatch = (error, info) => {
console.error(error) console.error(error)
@ -153,12 +155,28 @@ export default class PostCard extends React.PureComponent {
</div> </div>
} }
componentDidMount = () => {
app.cores.api.listenEvent(`post.update.${this.state.data._id}`, this.handleDataUpdate, "posts")
}
componentWillUnmount = () => {
app.cores.api.unlistenEvent(`post.update.${this.state.data._id}`, this.handleDataUpdate, "posts")
}
render() { render() {
return <article return <motion.div
initial={{ y: -100, opacity: 0 }}
animate={{ y: 0, opacity: 1, }}
exit={{ scale: 0, opacity: 0 }}
transition={{
duration: 0.1,
}}
layout
key={this.props.index} key={this.props.index}
id={this.props.data._id} id={this.state.data._id}
post_id={this.state.data._id}
style={this.props.style} style={this.props.style}
user-id={this.props.data.user_id} user-id={this.state.data.user_id}
context-menu={"postCard-context"} context-menu={"postCard-context"}
className={classnames( className={classnames(
"post_card", "post_card",
@ -168,8 +186,9 @@ export default class PostCard extends React.PureComponent {
)} )}
> >
<PostHeader <PostHeader
postData={this.props.data} postData={this.state.data}
onDoubleClick={this.onDoubleClick} onDoubleClick={this.onDoubleClick}
disableReplyTag={this.props.disableReplyTag}
/> />
<div <div
@ -180,37 +199,43 @@ export default class PostCard extends React.PureComponent {
> >
<div className="message"> <div className="message">
{ {
processString(messageRegexs)(this.props.data.message ?? "") processString(messageRegexs)(this.state.data.message ?? "")
} }
</div> </div>
{ {
!this.props.disableAttachments && this.props.data.attachments && this.props.data.attachments.length > 0 && <PostAttachments !this.props.disableAttachments && this.state.data.attachments && this.state.data.attachments.length > 0 && <PostAttachments
attachments={this.props.data.attachments} attachments={this.state.data.attachments}
flags={this.props.data.flags} flags={this.state.data.flags}
/> />
} }
</div> </div>
<PostActions <PostActions
user_id={this.props.data.user_id} user_id={this.state.data.user_id}
likesCount={this.state.countLikes} likesCount={this.state.countLikes}
commentsCount={this.state.countComments} repliesCount={this.state.countReplies}
defaultLiked={this.state.hasLiked} defaultLiked={this.state.hasLiked}
defaultSaved={this.state.hasSaved} defaultSaved={this.state.hasSaved}
actions={{ actions={{
onClickLike: this.onClickLike, onClickLike: this.onClickLike,
onClickEdit: this.onClickEdit, onClickEdit: this.onClickEdit,
onClickDelete: this.onClickDelete, onClickDelete: this.onClickDelete,
onClickSave: this.onClickSave, onClickSave: this.onClickSave,
onClickComments: this.onClickComments, onClickReply: this.onClickReply,
}} }}
/> />
<CommentsCard {
post_id={this.props.data._id} !this.props.disableHasReplies && this.state.hasReplies && <>
visible={this.state.open} <Divider />
/> <h1>View replies</h1>
</article> </>
}
</motion.div>
} }
} }

View File

@ -13,7 +13,7 @@
margin: auto; margin: auto;
gap: 15px; gap: 15px;
padding: 17px 17px 0px 17px; padding: 17px 17px 10px 17px;
background-color: var(--background-color-accent); background-color: var(--background-color-accent);
@ -22,8 +22,6 @@
color: rgba(var(--background-color-contrast)); color: rgba(var(--background-color-contrast));
transition: all 0.2s ease-in-out;
h1, h1,
h2, h2,
h3, h3,

View File

@ -1,24 +1,21 @@
import React from "react" import React from "react"
import * as antd from "antd" import * as antd from "antd"
import classnames from "classnames" import classnames from "classnames"
import { DateTime } from "luxon"
import humanSize from "@tsmx/human-readable" import humanSize from "@tsmx/human-readable"
import PostLink from "components/PostLink"
import { Icons } from "components/Icons" import { Icons } from "components/Icons"
import clipboardEventFileToFile from "utils/clipboardEventFileToFile"
import clipboardEventFileToFile from "utils/clipboardEventFileToFile"
import PostModel from "models/post" import PostModel from "models/post"
import "./index.less" import "./index.less"
const DEFAULT_POST_POLICY = { const DEFAULT_POST_POLICY = {
maxMessageLength: 512, maxMessageLength: 512,
acceptedMimeTypes: ["image/gif", "image/png", "image/jpeg", "image/bmp"], acceptedMimeTypes: ["image/gif", "image/png", "image/jpeg", "image/bmp", "video/*"],
maximumFileSize: 10 * 1024 * 1024,
maximunFilesPerRequest: 10 maximunFilesPerRequest: 10
} }
// TODO: Fix close window when post created
export default class PostCreator extends React.Component { export default class PostCreator extends React.Component {
state = { state = {
@ -92,15 +89,30 @@ export default class PostCreator extends React.Component {
const payload = { const payload = {
message: postMessage, message: postMessage,
attachments: postAttachments, attachments: postAttachments,
timestamp: DateTime.local().toISO(), //timestamp: DateTime.local().toISO(),
} }
const response = await PostModel.create(payload).catch(error => { let response = null
console.error(error)
antd.message.error(error)
return false if (this.props.reply_to) {
}) payload.reply_to = this.props.reply_to
}
if (this.props.edit_post) {
response = await PostModel.update(this.props.edit_post, payload).catch(error => {
console.error(error)
antd.message.error(error)
return false
})
} else {
response = await PostModel.create(payload).catch(error => {
console.error(error)
antd.message.error(error)
return false
})
}
this.setState({ this.setState({
loading: false loading: false
@ -116,6 +128,10 @@ export default class PostCreator extends React.Component {
if (typeof this.props.close === "function") { if (typeof this.props.close === "function") {
this.props.close() this.props.close()
} }
if (this.props.reply_to) {
app.navigation.goToPost(this.props.reply_to)
}
} }
} }
@ -182,8 +198,6 @@ export default class PostCreator extends React.Component {
}) })
} }
console.log(change)
switch (change.file.status) { switch (change.file.status) {
case "uploading": { case "uploading": {
this.toggleUploaderVisibility(false) this.toggleUploaderVisibility(false)
@ -424,9 +438,37 @@ export default class PostCreator extends React.Component {
dialog.click() dialog.click()
} }
componentDidMount() { componentDidMount = async () => {
if (this.props.edit_post) {
await this.setState({
loading: true,
postId: this.props.edit_post,
})
const post = await PostModel.getPost({ post_id: this.props.edit_post })
await this.setState({
loading: false,
postMessage: post.message,
postAttachments: post.attachments.map((attachment) => {
return {
...attachment,
uid: attachment.id,
}
}),
fileList: post.attachments.map((attachment) => {
return {
...attachment,
uid: attachment.id,
id: attachment.id,
thumbUrl: attachment.url,
status: "done",
}
}),
})
}
// fetch the posting policy // fetch the posting policy
this.fetchUploadPolicy() //this.fetchUploadPolicy()
// add a listener to the window // add a listener to the window
document.addEventListener("paste", this.handlePaste) document.addEventListener("paste", this.handlePaste)
@ -448,6 +490,10 @@ export default class PostCreator extends React.Component {
render() { render() {
const { postMessage, fileList, loading, uploaderVisible, postingPolicy } = this.state const { postMessage, fileList, loading, uploaderVisible, postingPolicy } = this.state
const editMode = !!this.props.edit_post
const showHeader = !!this.props.edit_post || this.props.reply_to
return <div return <div
className={"postCreator"} className={"postCreator"}
ref={this.creatorRef} ref={this.creatorRef}
@ -455,6 +501,37 @@ export default class PostCreator extends React.Component {
onDragLeave={this.handleDrag} onDragLeave={this.handleDrag}
style={this.props.style} style={this.props.style}
> >
{
showHeader && <div className="postCreator-header">
{
this.props.edit_post && <div className="postCreator-header-indicator">
<p>
<Icons.MdEdit />
Editing post
</p>
</div>
}
{
this.props.reply_to && <div className="postCreator-header-indicator">
<p>
<Icons.MdReply />
Replaying to
</p>
<PostLink
post_id={this.props.reply_to}
onClick={() => {
this.props.close()
app.navigation.goToPost(this.props.reply_to)
}}
/>
</div>
}
</div>
}
<div className="textInput"> <div className="textInput">
<div className="avatar"> <div className="avatar">
<img src={app.userData?.avatar} /> <img src={app.userData?.avatar} />
@ -475,7 +552,7 @@ export default class PostCreator extends React.Component {
type="primary" type="primary"
disabled={loading || !this.canSubmit()} disabled={loading || !this.canSubmit()}
onClick={this.submit} onClick={this.submit}
icon={loading ? <Icons.LoadingOutlined spin /> : <Icons.Send />} icon={loading ? <Icons.LoadingOutlined spin /> : (editMode ? <Icons.MdEdit /> : <Icons.Send />)}
/> />
</div> </div>
</div> </div>

View File

@ -17,7 +17,29 @@
background-color: var(--background-color-accent); background-color: var(--background-color-accent);
padding: 15px; padding: 15px;
//transition: all 250ms ease-in-out; gap: 10px;
.postCreator-header {
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
p {
margin: 0;
}
.postCreator-header-indicator {
display: flex;
flex-direction: row;
gap: 7px;
align-items: center;
}
}
.actions { .actions {
display: inline-flex; display: inline-flex;
@ -58,8 +80,6 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
padding: 10px;
overflow: hidden; overflow: hidden;
overflow-x: scroll; overflow-x: scroll;
@ -125,6 +145,8 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center;
width: 100%; width: 100%;
overflow-x: auto; overflow-x: auto;
@ -146,6 +168,8 @@
height: fit-content; height: fit-content;
border-radius: @file_preview_borderRadius; border-radius: @file_preview_borderRadius;
margin: 0;
} }
} }
} }

View File

@ -0,0 +1,28 @@
import React from "react"
import { Tag } from "antd"
import "./index.less"
const PostLink = (props) => {
if (!props.post_id) {
return null
}
return <Tag
className="post-link"
color="geekblue"
onClick={() => {
if (props.onClick) {
return props.onClick()
}
app.navigation.goToPost(props.post_id)
}}
>
<span>
#{props.post_id}
</span>
</Tag>
}
export default PostLink

View File

@ -0,0 +1,24 @@
.post-link {
display: flex;
flex-direction: row;
align-items: center;
border-radius: 8px;
font-size: 0.7rem;
font-weight: 800;
font-family: "DM Mono", monospace;
cursor: pointer;
overflow: hidden;
span {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
margin: 0;
}
}

View File

@ -1,6 +1,7 @@
import React from "react" import React from "react"
import * as antd from "antd" import * as antd from "antd"
import { Icons } from "components/Icons" import { Icons } from "components/Icons"
import { AnimatePresence } from "framer-motion"
import PostCard from "components/PostCard" import PostCard from "components/PostCard"
import PlaylistTimelineEntry from "components/Music/PlaylistTimelineEntry" import PlaylistTimelineEntry from "components/Music/PlaylistTimelineEntry"
@ -41,21 +42,21 @@ const Entry = React.memo((props) => {
return React.createElement(typeToComponent[data.type ?? "post"] ?? PostCard, { return React.createElement(typeToComponent[data.type ?? "post"] ?? PostCard, {
key: data._id, key: data._id,
data: data, data: data,
//disableAttachments: true, disableReplyTag: props.disableReplyTag,
events: { events: {
onClickLike: props.onLikePost, onClickLike: props.onLikePost,
onClickSave: props.onSavePost, onClickSave: props.onSavePost,
onClickDelete: props.onDeletePost, onClickDelete: props.onDeletePost,
onClickEdit: props.onEditPost, onClickEdit: props.onEditPost,
onClickReply: props.onReplyPost,
onDoubleClick: props.onDoubleClick,
}, },
}) })
}) })
const PostList = (props) => { const PostList = React.forwardRef((props, ref) => {
const parentRef = React.useRef()
return <LoadMore return <LoadMore
ref={parentRef} ref={ref}
className="post-list" className="post-list"
loadingComponent={LoadingComponent} loadingComponent={LoadingComponent}
noResultComponent={NoResultComponent} noResultComponent={NoResultComponent}
@ -77,40 +78,20 @@ const PostList = (props) => {
</div> </div>
} }
{ <AnimatePresence>
props.list.map((data) => {
return <Entry
key={data._id}
data={data}
onLikePost={props.onLikePost}
onSavePost={props.onSavePost}
onDeletePost={props.onDeletePost}
onEditPost={props.onEditPost}
/>
})
}
{/* <For
each={props.list}
style={{
height: `100%`,
width: `100%`,
}}
as="div"
>
{ {
(data) => <Entry props.list.map((data) => {
key={data._id} return <Entry
data={data} key={data._id}
onLikePost={props.onLikePost} data={data}
onSavePost={props.onSavePost} {...props}
onDeletePost={props.onDeletePost} />
onEditPost={props.onEditPost} })
/>
} }
</For> */} </AnimatePresence>
</LoadMore> </LoadMore>
}
})
export class PostsListsComponent extends React.Component { export class PostsListsComponent extends React.Component {
state = { state = {
@ -238,12 +219,15 @@ export class PostsListsComponent extends React.Component {
addPost: this.addPost, addPost: this.addPost,
removePost: this.removePost, removePost: this.removePost,
addRandomPost: () => { addRandomPost: () => {
const randomId = Math.random().toString(36).substring(7)
this.addPost({ this.addPost({
_id: Math.random().toString(36).substring(7), _id: randomId,
message: `Random post ${Math.random().toString(36).substring(7)}`, message: `Random post ${randomId}`,
user: { user: {
_id: Math.random().toString(36).substring(7), _id: randomId,
username: "random user", username: "random user",
avatar: `https://api.dicebear.com/7.x/thumbs/svg?seed=${randomId}`,
} }
}) })
}, },
@ -336,7 +320,7 @@ export class PostsListsComponent extends React.Component {
console.error(`The event "${event}" is not defined in the timelineWsEvents object`) console.error(`The event "${event}" is not defined in the timelineWsEvents object`)
} }
app.cores.api.listenEvent(event, this.timelineWsEvents[event]) app.cores.api.listenEvent(event, this.timelineWsEvents[event], "posts")
}) })
} }
} }
@ -358,7 +342,7 @@ export class PostsListsComponent extends React.Component {
console.error(`The event "${event}" is not defined in the timelineWsEvents object`) console.error(`The event "${event}" is not defined in the timelineWsEvents object`)
} }
app.cores.api.unlistenEvent(event, this.timelineWsEvents[event]) app.cores.api.unlistenEvent(event, this.timelineWsEvents[event], "posts")
}) })
} }
} }
@ -370,7 +354,7 @@ export class PostsListsComponent extends React.Component {
window._hacks = null window._hacks = null
} }
componentDidUpdate = async (prevProps) => { componentDidUpdate = async (prevProps, prevState) => {
if (prevProps.list !== this.props.list) { if (prevProps.list !== this.props.list) {
this.setState({ this.setState({
list: this.props.list, list: this.props.list,
@ -398,6 +382,22 @@ export class PostsListsComponent extends React.Component {
return result return result
} }
onEditPost = (data) => {
app.controls.openPostCreator({
edit_post: data._id,
})
}
onReplyPost = (data) => {
app.controls.openPostCreator({
reply_to: data._id,
})
}
onDoubleClickPost = (data) => {
app.navigation.goToPost(data._id)
}
onDeletePost = async (data) => { onDeletePost = async (data) => {
antd.Modal.confirm({ antd.Modal.confirm({
title: "Are you sure you want to delete this post?", title: "Are you sure you want to delete this post?",
@ -444,13 +444,16 @@ export class PostsListsComponent extends React.Component {
} }
const PostListProps = { const PostListProps = {
listRef: this.listRef,
list: this.state.list, list: this.state.list,
disableReplyTag: this.props.disableReplyTag,
onLikePost: this.onLikePost, onLikePost: this.onLikePost,
onSavePost: this.onSavePost, onSavePost: this.onSavePost,
onDeletePost: this.onDeletePost, onDeletePost: this.onDeletePost,
onEditPost: this.onEditPost, onEditPost: this.onEditPost,
onReplyPost: this.onReplyPost,
onDoubleClick: this.onDoubleClickPost,
onLoadMore: this.onLoadMore, onLoadMore: this.onLoadMore,
hasMore: this.state.hasMore, hasMore: this.state.hasMore,
@ -463,12 +466,14 @@ export class PostsListsComponent extends React.Component {
if (app.isMobile) { if (app.isMobile) {
return <PostList return <PostList
ref={this.listRef}
{...PostListProps} {...PostListProps}
/> />
} }
return <div className="post-list_wrapper"> return <div className="post-list_wrapper">
<PostList <PostList
ref={this.listRef}
{...PostListProps} {...PostListProps}
/> />
</div> </div>

View File

@ -59,7 +59,7 @@ html {
position: relative; position: relative;
// WARN: Only use if is a performance issue (If is using virtualized list) // WARN: Only use if is a performance issue (If is using virtualized list)
will-change: transform; //will-change: transform;
overflow: hidden; overflow: hidden;
overflow-y: overlay; overflow-y: overlay;
@ -77,10 +77,14 @@ html {
//margin: auto; //margin: auto;
z-index: 150; z-index: 150;
background-color: var(--background-color-accent);
.post_card { .post_card {
border-radius: 0; border-radius: 0;
border-bottom: 2px solid var(--border-color); border-bottom: 2px solid var(--border-color);
background-color: transparent;
&:first-child { &:first-child {
border-radius: 8px; border-radius: 8px;
@ -95,33 +99,11 @@ html {
border-bottom-left-radius: 8px; border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px; border-bottom-right-radius: 8px;
} }
}
.playlistTimelineEntry { &:last-of-type {
border-radius: 0; border-bottom: none;
border-bottom: 2px solid var(--border-color);
&:last-child {
border-top-left-radius: 0px;
border-top-right-radius: 0px;
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
} }
} }
.postCard {
width: 100%;
min-width: 0;
// FIXME: This is a walkaround for a bug when a post contains multiple attachments cause a overflow
max-width: unset;
}
.playlistTimelineEntry {
width: 100%;
min-width: 0;
max-width: unset;
}
} }
.resume_btn_wrapper { .resume_btn_wrapper {

View File

@ -52,7 +52,7 @@ export default class SyncRoomCard extends React.Component {
} }
checkLatency = () => { checkLatency = () => {
const instance = app.cores.api.instance().wsInstances.music const instance = app.cores.api.instance().sockets.music
if (instance) { if (instance) {
this.setState({ this.setState({
@ -67,7 +67,7 @@ export default class SyncRoomCard extends React.Component {
}) })
// chat instance // chat instance
const chatInstance = app.cores.api.instance().wsInstances.chat const chatInstance = app.cores.api.instance().sockets.chat
if (chatInstance) { if (chatInstance) {
Object.keys(this.chatEvents).forEach((event) => { Object.keys(this.chatEvents).forEach((event) => {
@ -92,7 +92,7 @@ export default class SyncRoomCard extends React.Component {
} }
// chat instance // chat instance
const chatInstance = app.cores.api.instance().wsInstances.chat const chatInstance = app.cores.api.instance().sockets.chat
if (chatInstance) { if (chatInstance) {
Object.keys(this.chatEvents).forEach((event) => { Object.keys(this.chatEvents).forEach((event) => {
@ -231,7 +231,7 @@ export default class SyncRoomCard extends React.Component {
<div className="latency_display"> <div className="latency_display">
<span> <span>
{ {
app.cores.api.instance().wsInstances.music.latency ?? "..." app.cores.api.instance().sockets.music.latency ?? "..."
}ms }ms
</span> </span>
</div> </div>

View File

@ -1,10 +1,15 @@
import React from "react" import React from "react"
import { Button, Upload } from "antd" import { Upload, Progress } from "antd"
import classnames from "classnames"
import { Icons } from "components/Icons" import { Icons } from "components/Icons"
import useHacks from "hooks/useHacks"
import "./index.less"
export default (props) => { export default (props) => {
const [uploading, setUploading] = React.useState(false) const [uploading, setUploading] = React.useState(false)
const [progess, setProgess] = React.useState(null)
const handleOnStart = (file_uid, file) => { const handleOnStart = (file_uid, file) => {
if (typeof props.onStart === "function") { if (typeof props.onStart === "function") {
@ -32,35 +37,37 @@ export default (props) => {
const handleUpload = async (req) => { const handleUpload = async (req) => {
setUploading(true) setUploading(true)
setProgess(1)
handleOnStart(req.file.uid, req.file) handleOnStart(req.file.uid, req.file)
const response = await app.cores.remoteStorage.uploadFile(req.file, { await app.cores.remoteStorage.uploadFile(req.file, {
onProgress: (file, progress) => { onProgress: (file, progress) => {
return handleOnProgress(file.uid, progress) setProgess(progress)
} handleOnProgress(file.uid, progress)
}).catch((err) => { },
app.notification.new({ onError: (file, error) => {
title: "Could not upload file", setProgess(null)
description: err handleOnError(file.uid, error)
}, { setUploading(false)
type: "error" },
}) onFinish: (file, response) => {
if (typeof props.ctx?.onUpdateItem === "function") {
props.ctx.onUpdateItem(response.url)
}
return handleOnError(req.file.uid, err) if (typeof props.onUploadDone === "function") {
props.onUploadDone(response)
}
setUploading(false)
handleOnSuccess(req.file.uid, response)
setTimeout(() => {
setProgess(null)
}, 1000)
},
}) })
if (typeof props.ctx?.onUpdateItem === "function") {
props.ctx.onUpdateItem(response.url)
}
if (typeof props.onUploadDone === "function") {
await props.onUploadDone(response)
}
setUploading(false)
return handleOnSuccess(req.file.uid, response)
} }
return <Upload return <Upload
@ -69,25 +76,43 @@ export default (props) => {
props.multiple ?? false props.multiple ?? false
} }
accept={ accept={
props.accept ?? "image/*" props.accept ?? [
"image/*",
"video/*",
"audio/*",
]
} }
progress={false} progress={false}
fileList={[]} fileList={[]}
> className={classnames(
<Button "uploadButton",
icon={props.icon ?? <Icons.Upload {
style={{ ["uploading"]: !!progess || uploading
margin: 0
}}
/>}
loading={uploading}
type={
props.type ?? "round"
} }
> )}
disabled={uploading}
>
<div className="uploadButton-content">
{
!progess && (props.icon ?? <Icons.Upload
style={{
margin: 0
}}
/>)
}
{
progess && <Progress
type="circle"
percent={progess}
strokeWidth={20}
format={() => null}
/>
}
{ {
props.children ?? "Upload" props.children ?? "Upload"
} }
</Button> </div>
</Upload> </Upload>
} }

View File

@ -0,0 +1,71 @@
.uploadButton {
display: inline-flex;
flex-direction: row;
align-items: center;
justify-content: center;
background-color: var(--background-color-accent);
border-radius: 8px;
padding: 5px 15px;
cursor: pointer;
transition: all 150ms ease-in-out;
&.uploading {
border-radius: 12px;
.uploadButton-content {
gap: 15px;
}
}
.ant-upload {
display: inline-flex;
flex-direction: row;
align-items: center;
justify-content: center;
}
.uploadButton-content {
display: inline-flex;
flex-direction: row;
align-items: center;
gap: 7px;
transition: all 150ms ease-in-out;
.ant-progress {
position: relative;
display: inline-flex;
flex-direction: row;
align-items: center;
justify-content: center;
height: 17px;
width: 17px;
.ant-progress-inner,
.ant-progress-circle {
position: absolute;
top: 0;
left: 0;
width: 100% !important;
height: 100% !important;
}
svg {
margin: 0 !important;
}
}
}
}

View File

@ -113,7 +113,7 @@ export const UserCard = React.forwardRef((props, ref) => {
<div className="username"> <div className="username">
<div className="username_text"> <div className="username_text">
<h1> <h1>
{user.fullName || user.username} {user.public_name || user.username}
{user.verified && <Icons.verifiedBadge />} {user.verified && <Icons.verifiedBadge />}
</h1> </h1>
<span> <span>

View File

@ -256,8 +256,6 @@ html {
outline: 1px solid var(--border-color); outline: 1px solid var(--border-color);
filter: drop-shadow(0 0 20px var(--border-color));
h1, h1,
h2, h2,
h3, h3,

View File

@ -15,7 +15,6 @@ export { default as StepsForm } from "./StepsForm"
export { default as SearchButton } from "./SearchButton" export { default as SearchButton } from "./SearchButton"
export { default as Skeleton } from "./Skeleton" export { default as Skeleton } from "./Skeleton"
export { default as Navigation } from "./Navigation" export { default as Navigation } from "./Navigation"
export { default as ImageUploader } from "./ImageUploader"
export { default as ImageViewer } from "./ImageViewer" export { default as ImageViewer } from "./ImageViewer"
export { default as Image } from "./Image" export { default as Image } from "./Image"
export { default as LoadMore } from "./LoadMore" export { default as LoadMore } from "./LoadMore"

View File

@ -2,8 +2,8 @@ import Core from "evite/src/core"
import createClient from "comty.js" import createClient from "comty.js"
import measurePing from "comty.js/handlers/measurePing" import request from "comty.js/request"
import request from "comty.js/handlers/request" import measurePing from "comty.js/helpers/measurePing"
import useRequest from "comty.js/hooks/useRequest" import useRequest from "comty.js/hooks/useRequest"
import { reconnectWebsockets, disconnectWebsockets } from "comty.js" import { reconnectWebsockets, disconnectWebsockets } from "comty.js"
@ -13,11 +13,11 @@ export default class APICore extends Core {
static bgColor = "coral" static bgColor = "coral"
static textColor = "black" static textColor = "black"
instance = null client = null
public = { public = {
instance: function () { client: function () {
return this.instance return this.client
}.bind(this), }.bind(this),
customRequest: request, customRequest: request,
listenEvent: this.listenEvent.bind(this), listenEvent: this.listenEvent.bind(this),
@ -28,82 +28,45 @@ export default class APICore extends Core {
disconnectWebsockets: disconnectWebsockets, disconnectWebsockets: disconnectWebsockets,
} }
listenEvent(key, handler, instance) { listenEvent(key, handler, instance = "default") {
if (!this.instance.wsInstances[instance ?? "default"]) { if (!this.client.sockets[instance]) {
console.error(`[API] Websocket instance ${instance} not found`) console.error(`[API] Websocket instance ${instance} not found`)
return false return false
} }
return this.instance.wsInstances[instance ?? "default"].on(key, handler) return this.client.sockets[instance].on(key, handler)
} }
unlistenEvent(key, handler, instance) { unlistenEvent(key, handler, instance = "default") {
if (!this.instance.wsInstances[instance ?? "default"]) { if (!this.client.sockets[instance]) {
console.error(`[API] Websocket instance ${instance} not found`) console.error(`[API] Websocket instance ${instance} not found`)
return false return false
} }
return this.instance.wsInstances[instance ?? "default"].off(key, handler) return this.client.sockets[instance].off(key, handler)
}
pendingPingsFromInstance = {}
createPingIntervals() {
// Object.keys(this.instance.wsInstances).forEach((instance) => {
// this.console.debug(`[API] Creating ping interval for ${instance}`)
// if (this.instance.wsInstances[instance].pingInterval) {
// clearInterval(this.instance.wsInstances[instance].pingInterval)
// }
// this.instance.wsInstances[instance].pingInterval = setInterval(() => {
// if (this.instance.wsInstances[instance].pendingPingTry && this.instance.wsInstances[instance].pendingPingTry > 3) {
// this.console.debug(`[API] Ping timeout for ${instance}`)
// return clearInterval(this.instance.wsInstances[instance].pingInterval)
// }
// const timeStart = Date.now()
// //this.console.debug(`[API] Ping ${instance}`, this.instance.wsInstances[instance].pendingPingTry)
// this.instance.wsInstances[instance].emit("ping", () => {
// this.instance.wsInstances[instance].latency = Date.now() - timeStart
// this.instance.wsInstances[instance].pendingPingTry = 0
// })
// this.instance.wsInstances[instance].pendingPingTry = this.instance.wsInstances[instance].pendingPingTry ? this.instance.wsInstances[instance].pendingPingTry + 1 : 1
// }, 5000)
// // clear interval on close
// this.instance.wsInstances[instance].on("close", () => {
// clearInterval(this.instance.wsInstances[instance].pingInterval)
// })
// })
} }
async onInitialize() { async onInitialize() {
this.instance = await createClient({ this.client = await createClient({
enableWs: true, enableWs: true,
}) })
this.instance.eventBus.on("auth:login_success", () => { this.client.eventBus.on("auth:login_success", () => {
app.eventBus.emit("auth:login_success") app.eventBus.emit("auth:login_success")
}) })
this.instance.eventBus.on("auth:logout_success", () => { this.client.eventBus.on("auth:logout_success", () => {
app.eventBus.emit("auth:logout_success") app.eventBus.emit("auth:logout_success")
}) })
this.instance.eventBus.on("session.invalid", (error) => { this.client.eventBus.on("session.invalid", (error) => {
app.eventBus.emit("session.invalid", error) app.eventBus.emit("session.invalid", error)
}) })
// make a basic request to check if the API is available // make a basic request to check if the API is available
await this.instance.instances["default"]({ await this.client.baseRequest({
method: "head", method: "head",
url: "/", url: "/",
}).catch((error) => { }).catch((error) => {
@ -115,10 +78,6 @@ export default class APICore extends Core {
`) `)
}) })
this.console.debug("[API] Attached to", this.instance) return this.client
//this.createPingIntervals()
return this.instance
} }
} }

View File

@ -0,0 +1,44 @@
import { Haptics } from "@capacitor/haptics"
const NotfTypeToAudio = {
info: "notification",
success: "notification",
warning: "warn",
error: "error",
}
class NotificationFeedback {
static getSoundVolume = () => {
return (window.app.cores.settings.get("notifications_sound_volume") ?? 50) / 100
}
static playHaptic = async (options = {}) => {
const vibrationEnabled = options.vibrationEnabled ?? window.app.cores.settings.get("notifications_vibrate")
if (vibrationEnabled) {
await Haptics.vibrate()
}
}
static playAudio = (options = {}) => {
const soundEnabled = options.soundEnabled ?? window.app.cores.settings.get("notifications_sound")
const soundVolume = options.soundVolume ? options.soundVolume / 100 : NotificationFeedback.getSoundVolume()
if (soundEnabled) {
if (typeof window.app.cores.sound?.play === "function") {
const sound = options.sound ?? NotfTypeToAudio[options.type] ?? "notification"
window.app.cores.sound.play(sound, {
volume: soundVolume,
})
}
}
}
static async feedback(type) {
NotificationFeedback.playHaptic(type)
NotificationFeedback.playAudio(type)
}
}
export default NotificationFeedback

View File

@ -0,0 +1,44 @@
import Core from "evite/src/core"
import NotificationUI from "./ui"
import NotificationFeedback from "./feedback"
export default class NotificationCore extends Core {
static namespace = "notifications"
static depenpencies = [
"api",
"settings",
]
#newNotifications = []
onEvents = {
"changeNotificationsSoundVolume": (value) => {
NotificationFeedback.playAudio({
soundVolume: value
})
},
"changeNotificationsVibrate": (value) => {
NotificationFeedback.playHaptic({
vibrationEnabled: value,
})
}
}
listenSockets = {
"notifications": {
"notification.new": (data) => {
this.new(data)
}
}
}
public = {
new: this.new,
}
async new(notification, options = {}) {
await NotificationUI.notify(notification, options)
await NotificationFeedback.feedback(options.type)
}
}

View File

@ -1,46 +1,10 @@
import Core from "evite/src/core"
import React from "react" import React from "react"
import { notification as Notf, Space, Button } from "antd" import { notification as Notf, Space, Button } from "antd"
import { Icons, createIconRender } from "components/Icons" import { Icons, createIconRender } from "components/Icons"
import { Translation } from "react-i18next" import { Translation } from "react-i18next"
import { Haptics } from "@capacitor/haptics"
const NotfTypeToAudio = { class NotificationUI {
info: "notification", static async notify(
success: "notification",
warning: "warn",
error: "error",
}
export default class NotificationCore extends Core {
static namespace = "notifications"
onEvents = {
"changeNotificationsSoundVolume": (value) => {
this.playAudio({ soundVolume: value })
},
"changeNotificationsVibrate": (value) => {
this.playHaptic({
vibrationEnabled: value,
})
}
}
registerToApp = {
notification: this
}
getSoundVolume = () => {
return (window.app.cores.settings.get("notifications_sound_volume") ?? 50) / 100
}
new = (notification, options = {}) => {
this.notify(notification, options)
this.playHaptic(options)
this.playAudio(options)
}
notify(
notification, notification,
options = { options = {
type: "info" type: "info"
@ -142,27 +106,6 @@ export default class NotificationCore extends Core {
return Notf[options.type](notfObj) return Notf[options.type](notfObj)
} }
playHaptic = async (options = {}) => {
const vibrationEnabled = options.vibrationEnabled ?? window.app.cores.settings.get("notifications_vibrate")
if (vibrationEnabled) {
await Haptics.vibrate()
}
}
playAudio = (options = {}) => {
const soundEnabled = options.soundEnabled ?? window.app.cores.settings.get("notifications_sound")
const soundVolume = options.soundVolume ? options.soundVolume / 100 : this.getSoundVolume()
if (soundEnabled) {
if (typeof window.app.cores.sound?.play === "function") {
const sound = options.sound ?? NotfTypeToAudio[options.type] ?? "notification"
window.app.cores.sound.play(sound, {
volume: soundVolume,
})
}
}
}
} }
export default NotificationUI

View File

@ -0,0 +1,186 @@
import EventBus from "evite/src/internals/eventBus"
import SessionModel from "models/session"
export default class ChunkedUpload {
constructor(params) {
this.endpoint = params.endpoint
this.file = params.file
this.headers = params.headers || {}
this.postParams = params.postParams
this.service = params.service ?? "default"
this.retries = params.retries ?? app.cores.settings.get("uploader.retries") ?? 3
this.delayBeforeRetry = params.delayBeforeRetry || 5
this.start = 0
this.chunk = null
this.chunkCount = 0
this.splitChunkSize = params.splitChunkSize || 1024 * 1024 * 10
this.totalChunks = Math.ceil(this.file.size / this.splitChunkSize)
this.retriesCount = 0
this.offline = false
this.paused = false
this.headers["Authorization"] = `Bearer ${SessionModel.token}`
this.headers["uploader-original-name"] = encodeURIComponent(this.file.name)
this.headers["uploader-file-id"] = this.uniqid(this.file)
this.headers["uploader-chunks-total"] = this.totalChunks
this.headers["provider-type"] = this.service
this.headers["chunk-size"] = this.splitChunkSize
this._reader = new FileReader()
this.eventBus = new EventBus()
this.validateParams()
this.nextSend()
console.debug("[Uploader] Created", {
splitChunkSize: this.splitChunkSize,
totalChunks: this.totalChunks,
totalSize: this.file.size,
})
// restart sync when back online
// trigger events when offline/back online
window.addEventListener("online", () => {
if (!this.offline) return
this.offline = false
this.eventBus.emit("online")
this.nextSend()
})
window.addEventListener("offline", () => {
this.offline = true
this.eventBus.emit("offline")
})
}
on(event, fn) {
this.eventBus.on(event, fn)
}
validateParams() {
if (!this.endpoint || !this.endpoint.length) throw new TypeError("endpoint must be defined")
if (this.file instanceof File === false) throw new TypeError("file must be a File object")
if (this.headers && typeof this.headers !== "object") throw new TypeError("headers must be null or an object")
if (this.postParams && typeof this.postParams !== "object") throw new TypeError("postParams must be null or an object")
if (this.splitChunkSize && (typeof this.splitChunkSize !== "number" || this.splitChunkSize === 0)) throw new TypeError("splitChunkSize must be a positive number")
if (this.retries && (typeof this.retries !== "number" || this.retries === 0)) throw new TypeError("retries must be a positive number")
if (this.delayBeforeRetry && (typeof this.delayBeforeRetry !== "number")) throw new TypeError("delayBeforeRetry must be a positive number")
}
uniqid(file) {
return Math.floor(Math.random() * 100000000) + Date.now() + this.file.size + "_tmp"
}
loadChunk() {
return new Promise((resolve) => {
const length = this.totalChunks === 1 ? this.file.size : this.splitChunkSize
const start = length * this.chunkCount
this._reader.onload = () => {
this.chunk = new Blob([this._reader.result], { type: "application/octet-stream" })
resolve()
}
this._reader.readAsArrayBuffer(this.file.slice(start, start + length))
})
}
sendChunk() {
const form = new FormData()
// send post fields on last request
if (this.chunkCount + 1 === this.totalChunks && this.postParams) Object.keys(this.postParams).forEach(key => form.append(key, this.postParams[key]))
form.append("file", this.chunk)
this.headers["uploader-chunk-number"] = this.chunkCount
return fetch(this.endpoint, { method: "POST", headers: this.headers, body: form })
}
manageRetries() {
if (this.retriesCount++ < this.retries) {
setTimeout(() => this.nextSend(), this.delayBeforeRetry * 1000)
this.eventBus.emit("fileRetry", {
message: `An error occured uploading chunk ${this.chunkCount}. ${this.retries - this.retriesCount} retries left`,
chunk: this.chunkCount,
retriesLeft: this.retries - this.retriesCount
})
return
}
this.eventBus.emit("error", {
message: `An error occured uploading chunk ${this.chunkCount}. No more retries, stopping upload`
})
}
async nextSend() {
if (this.paused || this.offline) {
return
}
await this.loadChunk()
const res = await this.sendChunk()
.catch((err) => {
if (this.paused || this.offline) return
this.console.error(err)
// this type of error can happen after network disconnection on CORS setup
this.manageRetries()
})
if (res.status === 200 || res.status === 201 || res.status === 204) {
if (++this.chunkCount < this.totalChunks) {
this.nextSend()
} else {
res.json().then((body) => {
this.eventBus.emit("finish", body)
})
}
const percentProgress = Math.round((100 / this.totalChunks) * this.chunkCount)
this.eventBus.emit("progress", {
percentProgress
})
}
// errors that might be temporary, wait a bit then retry
else if ([408, 502, 503, 504].includes(res.status)) {
if (this.paused || this.offline) return
this.manageRetries()
}
else {
if (this.paused || this.offline) return
try {
res.json().then((body) => {
this.eventBus.emit("error", {
message: `[${res.status}] ${body.error ?? body.message}`
})
})
} catch (error) {
this.eventBus.emit("error", {
message: `[${res.status}] ${res.statusText}`
})
}
}
}
togglePause() {
this.paused = !this.paused
if (!this.paused) {
this.nextSend()
}
}
}

View File

@ -1,171 +1,6 @@
import Core from "evite/src/core" import Core from "evite/src/core"
import EventBus from "evite/src/internals/eventBus"
import SessionModel from "models/session"
class ChunkedUpload { import ChunkedUpload from "./chunkedUpload"
constructor(params) {
this.endpoint = params.endpoint
this.file = params.file
this.headers = params.headers || {}
this.postParams = params.postParams
this.chunkSize = params.chunkSize || 1000000
this.service = params.service ?? "default"
this.retries = params.retries ?? app.cores.settings.get("uploader.retries") ?? 3
this.delayBeforeRetry = params.delayBeforeRetry || 5
this.start = 0
this.chunk = null
this.chunkCount = 0
this.totalChunks = Math.ceil(this.file.size / this.chunkSize)
this.retriesCount = 0
this.offline = false
this.paused = false
this.headers["Authorization"] = SessionModel.token
this.headers["uploader-original-name"] = encodeURIComponent(this.file.name)
this.headers["uploader-file-id"] = this.uniqid(this.file)
this.headers["uploader-chunks-total"] = this.totalChunks
this.headers["provider-type"] = this.service
this._reader = new FileReader()
this.eventBus = new EventBus()
this.validateParams()
this.sendChunks()
// restart sync when back online
// trigger events when offline/back online
window.addEventListener("online", () => {
if (!this.offline) return
this.offline = false
this.eventBus.emit("online")
this.sendChunks()
})
window.addEventListener("offline", () => {
this.offline = true
this.eventBus.emit("offline")
})
}
on(event, fn) {
this.eventBus.on(event, fn)
}
validateParams() {
if (!this.endpoint || !this.endpoint.length) throw new TypeError("endpoint must be defined")
if (this.file instanceof File === false) throw new TypeError("file must be a File object")
if (this.headers && typeof this.headers !== "object") throw new TypeError("headers must be null or an object")
if (this.postParams && typeof this.postParams !== "object") throw new TypeError("postParams must be null or an object")
if (this.chunkSize && (typeof this.chunkSize !== "number" || this.chunkSize === 0)) throw new TypeError("chunkSize must be a positive number")
if (this.retries && (typeof this.retries !== "number" || this.retries === 0)) throw new TypeError("retries must be a positive number")
if (this.delayBeforeRetry && (typeof this.delayBeforeRetry !== "number")) throw new TypeError("delayBeforeRetry must be a positive number")
}
uniqid(file) {
return Math.floor(Math.random() * 100000000) + Date.now() + this.file.size + "_tmp"
}
getChunk() {
return new Promise((resolve) => {
const length = this.totalChunks === 1 ? this.file.size : this.chunkSize * 1000 * 1000
const start = length * this.chunkCount
this._reader.onload = () => {
this.chunk = new Blob([this._reader.result], { type: "application/octet-stream" })
resolve()
}
this._reader.readAsArrayBuffer(this.file.slice(start, start + length))
})
}
sendChunk() {
const form = new FormData()
// send post fields on last request
if (this.chunkCount + 1 === this.totalChunks && this.postParams) Object.keys(this.postParams).forEach(key => form.append(key, this.postParams[key]))
form.append("file", this.chunk)
this.headers["uploader-chunk-number"] = this.chunkCount
return fetch(this.endpoint, { method: "POST", headers: this.headers, body: form })
}
manageRetries() {
if (this.retriesCount++ < this.retries) {
setTimeout(() => this.sendChunks(), this.delayBeforeRetry * 1000)
this.eventBus.emit("fileRetry", {
message: `An error occured uploading chunk ${this.chunkCount}. ${this.retries - this.retriesCount} retries left`,
chunk: this.chunkCount,
retriesLeft: this.retries - this.retriesCount
})
return
}
this.eventBus.emit("error", {
message: `An error occured uploading chunk ${this.chunkCount}. No more retries, stopping upload`
})
}
sendChunks() {
if (this.paused || this.offline) return
this.getChunk()
.then(() => this.sendChunk())
.then((res) => {
if (res.status === 200 || res.status === 201 || res.status === 204) {
if (++this.chunkCount < this.totalChunks) this.sendChunks()
else {
res.json().then((body) => {
this.eventBus.emit("finish", body)
})
}
const percentProgress = Math.round((100 / this.totalChunks) * this.chunkCount)
this.eventBus.emit("progress", {
percentProgress
})
}
// errors that might be temporary, wait a bit then retry
else if ([408, 502, 503, 504].includes(res.status)) {
if (this.paused || this.offline) return
this.manageRetries()
}
else {
if (this.paused || this.offline) return
this.eventBus.emit("error", {
message: `An error occured uploading chunk ${this.chunkCount}. Server responded with ${res.status}`
})
}
})
.catch((err) => {
if (this.paused || this.offline) return
this.console.error(err)
// this type of error can happen after network disconnection on CORS setup
this.manageRetries()
})
}
togglePause() {
this.paused = !this.paused
if (!this.paused) {
this.sendChunks()
}
}
}
export default class RemoteStorage extends Core { export default class RemoteStorage extends Core {
static namespace = "remoteStorage" static namespace = "remoteStorage"
@ -190,19 +25,15 @@ export default class RemoteStorage extends Core {
onProgress = () => { }, onProgress = () => { },
onFinish = () => { }, onFinish = () => { },
onError = () => { }, onError = () => { },
service = "default", service = "standard",
} = {}, } = {},
) { ) {
const apiEndpoint = app.cores.api.instance().instances.files.getUri()
// TODO: get value from settings
const chunkSize = 2 * 1000 * 1000 // 10MB
return new Promise((_resolve, _reject) => { return new Promise((_resolve, _reject) => {
const fn = async () => new Promise((resolve, reject) => { const fn = async () => new Promise((resolve, reject) => {
const uploader = new ChunkedUpload({ const uploader = new ChunkedUpload({
endpoint: `${apiEndpoint}/upload/chunk`, endpoint: `${app.cores.api.client().mainOrigin}/upload/chunk`,
chunkSize: chunkSize, // TODO: get chunk size from settings
splitChunkSize: 5 * 1024 * 1024, // 5MB in bytes
file: file, file: file,
service: service, service: service,
}) })
@ -210,6 +41,13 @@ export default class RemoteStorage extends Core {
uploader.on("error", ({ message }) => { uploader.on("error", ({ message }) => {
this.console.error("[Uploader] Error", message) this.console.error("[Uploader] Error", message)
app.notification.new({
title: "Could not upload file",
description: message
}, {
type: "error"
})
if (typeof onError === "function") { if (typeof onError === "function") {
onError(file, message) onError(file, message)
} }
@ -219,8 +57,6 @@ export default class RemoteStorage extends Core {
}) })
uploader.on("progress", ({ percentProgress }) => { uploader.on("progress", ({ percentProgress }) => {
//this.console.debug(`[Uploader] Progress: ${percentProgress}%`)
if (typeof onProgress === "function") { if (typeof onProgress === "function") {
onProgress(file, percentProgress) onProgress(file, percentProgress)
} }
@ -229,6 +65,12 @@ export default class RemoteStorage extends Core {
uploader.on("finish", (data) => { uploader.on("finish", (data) => {
this.console.debug("[Uploader] Finish", data) this.console.debug("[Uploader] Finish", data)
app.notification.new({
title: "File uploaded",
}, {
type: "success"
})
if (typeof onFinish === "function") { if (typeof onFinish === "function") {
onFinish(file, data) onFinish(file, data)
} }

View File

@ -1,60 +0,0 @@
import Core from "evite/src/core"
import socketio from "socket.io-client"
import remotes from "comty.js/remotes"
import SessionModel from "comty.js/models/session"
export default class RoomsController extends Core {
static namespace = "rooms"
connectedRooms = []
connectToRoom = async (roomId) => {
if (!this.checkRoomExists(roomId)) {
await this.createRoom(roomId)
}
const room = this.createRoomSocket(roomId)
this.connectedRooms.push(room)
return room
}
disconnectFromRoom = async (roomId) => {
if (!this.checkRoomExists(roomId)) {
throw new Error(`Room ${roomId} does not exist`)
}
const room = this.connectedRooms.find((room) => room.roomId === roomId)
room.leave()
this.connectedRooms = this.connectedRooms.filter((room) => room.roomId !== roomId)
return room
}
checkRoomExists = (roomId) => {
return this.connectedRooms.some((room) => room.roomId === roomId)
}
createRoomSocket = async (roomId) => {
let roomInterface = {
roomId: roomId,
socket: socketio(remotes.chat.origin, {
transports: ["websocket"],
query: {
room: roomId,
},
auth: SessionModel.token,
autoConnect: true,
}),
}
room.leave = () => {
roomInterface.socket.disconnect()
}
return roomInterface
}
}

View File

@ -189,7 +189,7 @@ class MusicSyncSubCore {
} }
async onInitialize() { async onInitialize() {
this.musicWs = this.ctx.CORES.api.instance.wsInstances.music this.musicWs = this.ctx.CORES.api.instance.sockets.music
Object.keys(this.hubEvents).forEach((eventName) => { Object.keys(this.hubEvents).forEach((eventName) => {
this.musicWs.on(eventName, this.hubEvents[eventName]) this.musicWs.on(eventName, this.hubEvents[eventName])
@ -371,6 +371,7 @@ class MusicSyncSubCore {
} }
export default class SyncCore extends Core { export default class SyncCore extends Core {
static disabled = true
static namespace = "sync" static namespace = "sync"
static dependencies = ["api", "player"] static dependencies = ["api", "player"]

View File

@ -6,7 +6,7 @@ export default class WidgetsCore extends Core {
static storeKey = "widgets" static storeKey = "widgets"
static get apiInstance() { static get apiInstance() {
return app.cores.api.instance().instances.marketplace return app.cores.api.client().baseRequest
} }
public = { public = {
@ -21,7 +21,7 @@ export default class WidgetsCore extends Core {
async onInitialize() { async onInitialize() {
try { try {
await WidgetsCore.apiInstance() //await WidgetsCore.apiInstance()
const currentStore = this.getInstalled() const currentStore = this.getInstalled()

View File

@ -0,0 +1,29 @@
import UserModel from "models/user"
import React from "react"
export default (props = {}) => {
const [firstLoad, setFirstLoad] = React.useState(true)
const [localData, setLocalData] = React.useState({})
React.useEffect(() => {
UserModel.getConfig().then((config) => {
setLocalData(config)
setFirstLoad(false)
})
}, [])
async function updateConfig(update) {
if (typeof props.onUpdate === "function") {
props.onUpdate(localData)
}
const config = await UserModel.updateConfig(update)
setLocalData(config)
}
return [
localData,
updateConfig,
firstLoad,
]
}

View File

@ -58,19 +58,17 @@ class Modal extends React.Component {
} }
handleClickOutside = (e) => { handleClickOutside = (e) => {
if (this.contentRef.current && !this.contentRef.current.contains(e.target)) { if (this.props.confirmOnOutsideClick) {
if (this.props.confirmOnOutsideClick) { return AntdModal.confirm({
return AntdModal.confirm({ title: this.props.confirmOnClickTitle ?? "Are you sure?",
title: this.props.confirmOnClickTitle ?? "Are you sure?", content: this.props.confirmOnClickContent ?? "Are you sure you want to close this window?",
content: this.props.confirmOnClickContent ?? "Are you sure you want to close this window?", onOk: () => {
onOk: () => { this.close()
this.close() }
} })
})
}
return this.close()
} }
return this.close()
} }
render() { render() {
@ -82,14 +80,15 @@ class Modal extends React.Component {
["framed"]: this.props.framed, ["framed"]: this.props.framed,
} }
)} )}
onTouchEnd={this.handleClickOutside}
onMouseDown={this.handleClickOutside}
> >
<div
id="mask_trigger"
onTouchEnd={this.handleClickOutside}
onMouseDown={this.handleClickOutside}
/>
<div <div
className="app_modal_content" className="app_modal_content"
ref={this.contentRef} ref={this.contentRef}
onTouchEnd={this.handleClickOutside}
onMouseDown={this.handleClickOutside}
style={this.props.frameContentStyle} style={this.props.frameContentStyle}
> >
{ {

View File

@ -18,6 +18,17 @@
transition: all 150ms ease-in-out; transition: all 150ms ease-in-out;
#mask_trigger {
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
}
&.framed { &.framed {
.app_modal_content { .app_modal_content {
display: flex; display: flex;

View File

@ -2,9 +2,10 @@ import React from "react"
import * as antd from "antd" import * as antd from "antd"
import classnames from "classnames" import classnames from "classnames"
import { Translation } from "react-i18next" import { Translation } from "react-i18next"
import { motion, AnimatePresence } from "framer-motion"
import { Icons } from "components/Icons" import { Icons } from "components/Icons"
import { Skeleton, FollowButton, UserCard } from "components" import { FollowButton, UserCard } from "components"
import { SessionModel, UserModel, FollowsModel } from "models" import { SessionModel, UserModel, FollowsModel } from "models"
import DetailsTab from "./tabs/details" import DetailsTab from "./tabs/details"
@ -21,36 +22,6 @@ const TabsComponent = {
"music": MusicTab, "music": MusicTab,
} }
const TabRender = React.memo((props, ref) => {
const [transitionActive, setTransitionActive] = React.useState(false)
const [activeKey, setActiveKey] = React.useState(props.renderKey)
React.useEffect(() => {
setTransitionActive(true)
setTimeout(() => {
setActiveKey(props.renderKey)
setTimeout(() => {
setTransitionActive(false)
}, 100)
}, 100)
}, [props.renderKey])
const Tab = TabsComponent[activeKey]
if (!Tab) {
return null
}
// forwards ref to the tab
return <div className={classnames("fade-opacity-active", { "fade-opacity-leave": transitionActive })}>
{
React.createElement(Tab, props)
}
</div>
})
export default class Account extends React.Component { export default class Account extends React.Component {
state = { state = {
requestedUser: null, requestedUser: null,
@ -66,16 +37,8 @@ export default class Account extends React.Component {
isNotExistent: false, isNotExistent: false,
} }
profileRef = React.createRef()
contentRef = React.createRef() contentRef = React.createRef()
coverComponent = React.createRef()
leftPanelRef = React.createRef()
actionsRef = React.createRef()
componentDidMount = async () => { componentDidMount = async () => {
app.layout.toggleCenteredContent(false) app.layout.toggleCenteredContent(false)
@ -129,13 +92,6 @@ export default class Account extends React.Component {
}) })
} }
onPostListTopVisibility = (to) => {
if (to) {
this.profileRef.current.classList.remove("topHidden")
} else {
this.profileRef.current.classList.add("topHidden")
}
}
onClickFollow = async () => { onClickFollow = async () => {
const result = await FollowsModel.toggleFollow({ const result = await FollowsModel.toggleFollow({
@ -165,8 +121,6 @@ export default class Account extends React.Component {
return return
} }
this.onPostListTopVisibility(true)
key = key.toLowerCase() key = key.toLowerCase()
if (this.state.tabActiveKey === key) { if (this.state.tabActiveKey === key) {
@ -195,11 +149,10 @@ export default class Account extends React.Component {
} }
return <div return <div
ref={this.profileRef}
className={classnames( className={classnames(
"accountProfile", "accountProfile",
{ {
["noCover"]: !user.cover, ["withCover"]: user.cover,
} }
)} )}
id="profile" id="profile"
@ -209,7 +162,6 @@ export default class Account extends React.Component {
className={classnames("cover", { className={classnames("cover", {
["expanded"]: this.state.coverExpanded ["expanded"]: this.state.coverExpanded
})} })}
ref={this.coverComponent}
style={{ backgroundImage: `url("${user.cover}")` }} style={{ backgroundImage: `url("${user.cover}")` }}
onClick={() => this.toggleCoverExpanded()} onClick={() => this.toggleCoverExpanded()}
id="profile-cover" id="profile-cover"
@ -217,18 +169,12 @@ export default class Account extends React.Component {
} }
<div className="panels"> <div className="panels">
<div <div className="leftPanel">
className="leftPanel"
ref={this.leftPanelRef}
>
<UserCard <UserCard
user={user} user={user}
/> />
<div <div className="actions">
className="actions"
ref={this.actionsRef}
>
<FollowButton <FollowButton
count={this.state.followersCount} count={this.state.followersCount}
onClick={this.onClickFollow} onClick={this.onClickFollow}
@ -239,17 +185,33 @@ export default class Account extends React.Component {
</div> </div>
<div <div
className="content" className="centerPanel"
ref={this.contentRef} ref={this.contentRef}
> >
<TabRender <AnimatePresence mode="wait">
renderKey={this.state.tabActiveKey} <motion.div
state={this.state} initial={{ opacity: 0, scale: 0.95 }}
onTopVisibility={this.onPostListTopVisibility} animate={{ opacity: 1, scale: 1 }}
/> exit={{ opacity: 0 }}
transition={{
duration: 0.15,
}}
key={this.state.tabActiveKey}
style={{
width: "100%",
}}
>
{
React.createElement(TabsComponent[this.state.tabActiveKey], {
onTopVisibility: this.onPostListTopVisibility,
state: this.state
})
}
</motion.div>
</AnimatePresence>
</div> </div>
<div className="tabMenuWrapper"> <div className="rightPanel">
<antd.Menu <antd.Menu
className="tabMenu" className="tabMenu"
mode={app.isMobile ? "horizontal" : "vertical"} mode={app.isMobile ? "horizontal" : "vertical"}

View File

@ -1,8 +1,12 @@
@import "theme/vars.less"; @import "theme/vars.less";
@borderRadius: 12px; @borderRadius: 12px;
@stickyCardTop: 20px;
@withCoverPanelElevation: 100px;
.accountProfile { .accountProfile {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -10,17 +14,20 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
&.noCover { &.withCover {
.panels { .panels {
padding-top: 0;
.leftPanel { .leftPanel {
transform: translate(0, 0) !important; position: sticky;
}
}
.userCard { top: calc(@withCoverPanelElevation + @stickyCardTop);
filter: none; left: 0;
transform: translate(0, -@withCoverPanelElevation);
.userCard {
filter: drop-shadow(0 0 20px var(--border-color));
}
}
} }
} }
@ -81,14 +88,14 @@
gap: 20px; gap: 20px;
padding-top: 20px;
height: 100%; height: 100%;
width: 100%; width: 100%;
.leftPanel { .leftPanel {
position: sticky; position: sticky;
top: 0; top: @stickyCardTop;
height: fit-content;
z-index: 55; z-index: 55;
@ -99,14 +106,6 @@
transition: all 0.3s ease-in-out; transition: all 0.3s ease-in-out;
transform: translate(0, -100px);
.userCard {
position: sticky;
top: 0;
z-index: 55;
}
.actions { .actions {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -131,25 +130,51 @@
} }
} }
.content { .centerPanel {
display: flex;
flex-direction: column;
align-items: center;
height: 100%; height: 100%;
width: 100%; width: 100%;
.fade-opacity-active { .fade-opacity-active {
height: 100%; height: 100%;
} }
.post-list_wrapper {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
.post-list {
width: 100%;
max-width: 900px;
.post_card {
width: 100%;
max-width: unset;
}
}
}
} }
.tabMenuWrapper { .rightPanel {
position: sticky; position: sticky;
top: 0; top: @stickyCardTop;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: var(--background-color-accent); background-color: var(--background-color-accent);
border-radius: 12px; border-radius: 12px;
padding: 20px; padding: 15px;
height: fit-content; height: fit-content;
width: fit-content; width: fit-content;
@ -158,6 +183,19 @@
justify-self: center; justify-self: center;
.ant-menu-item {
display: inline-flex;
align-items: center;
justify-content: flex-start;
padding: 5px 10px !important;
svg {
margin: 0 !important;
}
}
.ant-menu-item-selected { .ant-menu-item-selected {
background-color: var(--background-color-primary) !important; background-color: var(--background-color-primary) !important;
} }
@ -168,14 +206,12 @@
background-color: var(--background-color-accent); background-color: var(--background-color-accent);
padding: 20px; padding: 20px;
border-radius: @borderRadius; border-radius: @borderRadius;
width: 100%;
} }
@media (max-width: 1720px) { @media (max-width: 1720px) {
.panels { .panels {
.content {
width: 60%;
}
.leftPanel { .leftPanel {
.userCard { .userCard {
width: 300px; width: 300px;
@ -188,12 +224,12 @@
} }
} }
.tabMenuWrapper { .rightPanel {
width: fit-content; width: fit-content;
min-width: 0; min-width: 60px;
top: 0; align-items: center;
right: 0; justify-content: center;
padding: 10px !important; padding: 10px !important;

View File

@ -98,7 +98,7 @@ export default (props) => {
<div className="field_value"> <div className="field_value">
<p> <p>
{props.state.followers.length} {props.state.followersCount}
</p> </p>
</div> </div>
</div> </div>
@ -117,7 +117,7 @@ export default (props) => {
<div className="field_value"> <div className="field_value">
<p> <p>
{ {
getJoinLabel(Number(props.state.user.createdAt)) getJoinLabel(Number(props.state.user.created_at ?? props.state.user.createdAt))
} }
</p> </p>
</div> </div>

View File

@ -19,7 +19,7 @@ export default (props) => {
return <antd.Result return <antd.Result
status="warning" status="warning"
title="Failed to retrieve releases" title="Failed to retrieve releases"
subTitle={E_Releases} subTitle={E_Releases.message}
/> />
} }

View File

@ -2,19 +2,18 @@ import React from "react"
import { PostsList } from "components" import { PostsList } from "components"
import Post from "models/post" import Feed from "models/feed"
import "./index.less" import "./index.less"
export default class ExplorePosts extends React.Component { export default class ExplorePosts extends React.Component {
render() { render() {
return <PostsList return <PostsList
loadFromModel={Post.getExplorePosts} loadFromModel={Feed.getGlobalTimelineFeed}
watchTimeline={[ watchTimeline={[
"post.new", "post.new",
"post.delete", "post.delete",
"feed.new", "feed.new",
"feed.delete",
]} ]}
realtime realtime
/> />

View File

@ -16,8 +16,9 @@ const emptyListRender = () => {
export class SavedPosts extends React.Component { export class SavedPosts extends React.Component {
render() { render() {
return <PostsList return <PostsList
emptyListRender={emptyListRender}
loadFromModel={PostModel.getSavedPosts} loadFromModel={PostModel.getSavedPosts}
emptyListRender={emptyListRender}
realtime={false}
/> />
} }
} }

View File

@ -0,0 +1,80 @@
import React from "react"
import SearchButton from "components/SearchButton"
import { Icons, createIconRender } from "components/Icons"
import Image from "components/Image"
import "./index.less"
const FieldItem = (props) => {
return <div className="marketplace-field-item">
<div className="marketplace-field-item-image">
<Image
src={props.image}
/>
</div>
<div className="marketplace-field-item-info">
<h1>
{props.title}
</h1>
<p>
{props.description}
</p>
</div>
</div>
}
const ExtensionsBrowser = () => {
return <div className="marketplace-field">
<div className="marketplace-field-header">
<h1>
<Icons.MdCode />
Extensions
</h1>
</div>
<div className="marketplace-field-slider">
<FieldItem
title="Example Extension"
description="Description"
image="https://placehold.co/400x400"
/>
<FieldItem
title="Example Extension"
description="Description"
image="https://placehold.co/400x400"
/>
<FieldItem
title="Example Extension"
description="Description bla blalbabla blalbabla blalbabla blalbabla blalbabla blalba"
image="https://placehold.co/400x400"
/>
<FieldItem
title="Bad image resolution"
description="Description"
image="https://placehold.co/1920x1080"
/>
</div>
</div>
}
const Marketplace = () => {
return <div className="marketplace">
<div className="marketplace-header">
<div className="marketplace-header-card">
<h1>
Marketplace
</h1>
</div>
<SearchButton />
</div>
<ExtensionsBrowser />
<ExtensionsBrowser />
<ExtensionsBrowser />
</div>
}
export default Marketplace

View File

@ -0,0 +1,114 @@
.marketplace {
display: flex;
flex-direction: column;
gap: 30px;
width: 100%;
.marketplace-header {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
.marketplace-header-card {
display: flex;
flex-direction: row;
width: fit-content;
gap: 10px;
background-color: var(--background-color-accent);
padding: 10px 20px;
border-radius: 12px;
}
h1 {
font-family: "Space Grotesk", sans-serif;
font-size: 2rem;
margin: 0;
}
}
.marketplace-field {
display: flex;
flex-direction: column;
.marketplace-field-header {
}
.marketplace-field-slider {
display: flex;
flex-direction: row;
gap: 20px;
}
.marketplace-field-item {
position: relative;
display: flex;
flex-direction: column;
gap: 10px;
width: 250px;
height: 280px;
background-color: var(--background-color-accent);
border-radius: 12px;
padding: 10px;
.marketplace-field-item-image {
width: 100%;
height: 60%;
.lazy-load-image-background {
width: 100%;
height: 100%;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
overflow: hidden;
border-radius: 12px;
background-color: black;
}
}
.marketplace-field-item-info {
display: flex;
flex-direction: column;
gap: 7px;
height: 40%;
width: 100%;
overflow: hidden;
h1, p {
text-wrap: none;
text-overflow: ellipsis;
margin: 0;
}
}
}
}
}

View File

@ -1,44 +1,50 @@
import React from "react" import React from "react"
import * as antd from "antd" import * as antd from "antd"
import Post from "models/post" import PostCard from "components/PostCard"
import { PostCard, CommentsCard } from "components" import PostsList from "components/PostsList"
import PostService from "models/post"
import "./index.less" import "./index.less"
export default (props) => { export default (props) => {
const post_id = props.params.post_id const post_id = props.params.post_id
const [data, setData] = React.useState(null) const [loading, result, error, repeat] = app.cores.api.useRequest(PostService.getPost, {
post_id,
})
const loadData = async () => { if (error) {
setData(null) return <antd.Result
status="warning"
const data = await Post.getPost({ post_id }).catch(() => { title="Failed to retrieve post"
antd.message.error("Failed to get post") subTitle={error.message}
/>
return false
})
if (data) {
setData(data)
}
} }
React.useEffect(() => { if (loading) {
loadData()
}, [])
if (!data) {
return <antd.Skeleton active /> return <antd.Skeleton active />
} }
return <div className="postPage"> return <div className="post-page">
<div className="postWrapper"> <div className="post-page-original">
<PostCard data={data} fullmode /> <h1>Post</h1>
<PostCard
data={result}
/>
</div> </div>
<div className="commentsWrapper">
<CommentsCard post_id={data._id} /> <div className="post-page-replies">
<h1>Replies</h1>
<PostsList
disableReplyTag
loadFromModel={PostService.replies}
loadFromModelProps={{
post_id,
}}
/>
</div> </div>
</div> </div>
} }

View File

@ -1,30 +1,8 @@
.postPage { .post-page {
display: flex; display: flex;
flex-direction: row; flex-direction: column;
width: 100%; width: 100%;
height: 100vh;
overflow: hidden; gap: 20px;
.postWrapper {
margin: 0 10px;
height: 100%;
width: 70vw;
min-width: 70vw;
max-width: 70vw;
}
.commentsWrapper {
height: 100vh;
width: 100%;
min-width: 300px;
overflow: scroll;
margin: 0 10px;
}
} }

View File

@ -308,6 +308,10 @@ export default class SettingItemComponent extends React.PureComponent {
} }
} }
if (typeof this.props.onUpdate === "function") {
await this.props.onUpdate(updateValue)
}
// finaly update value // finaly update value
await this.setState({ await this.setState({
value: updateValue value: updateValue

View File

@ -16,50 +16,49 @@ import SettingItemComponent from "../SettingItemComponent"
export default class SettingTab extends React.Component { export default class SettingTab extends React.Component {
state = { state = {
loading: true, loading: true,
processedCtx: {} tab: null,
ctx: {},
} }
tab = composedTabs[this.props.activeKey] loadTab = async () => {
await this.setState({
loading: true,
processedCtx: {},
})
processCtx = async () => { const tab = composedTabs[this.props.activeKey]
if (typeof this.tab.ctxData === "function") {
this.setState({ loading: true })
const resultCtx = await this.tab.ctxData() let ctx = {}
console.log(resultCtx) if (typeof tab.ctxData === "function") {
ctx = await tab.ctxData()
this.setState({
loading: false,
processedCtx: resultCtx
})
} }
await this.setState({
tab: tab,
loading: false,
ctx: {
baseConfig: this.props.baseConfig,
...ctx
},
})
} }
// check if props.activeKey change // check if props.activeKey change
componentDidUpdate = async (prevProps) => { componentDidUpdate = async (prevProps) => {
if (prevProps.activeKey !== this.props.activeKey) { if (prevProps.activeKey !== this.props.activeKey) {
this.tab = composedTabs[this.props.activeKey] await this.loadTab()
this.setState({
loading: !!this.tab.ctxData,
processedCtx: {}
})
await this.processCtx()
} }
} }
componentDidMount = async () => { componentDidMount = async () => {
this.setState({ await this.loadTab()
loading: !!this.tab.ctxData, }
})
await this.processCtx() handleSettingUpdate = async (key, value) => {
if (typeof this.props.onUpdate === "function") {
this.setState({ await this.props.onUpdate(key, value)
loading: false }
})
} }
render() { render() {
@ -67,14 +66,16 @@ export default class SettingTab extends React.Component {
return <antd.Skeleton active /> return <antd.Skeleton active />
} }
if (this.tab.render) { const { ctx, tab } = this.state
return React.createElement(this.tab.render, {
ctx: this.state.processedCtx if (tab.render) {
return React.createElement(tab.render, {
ctx: ctx,
}) })
} }
if (this.props.withGroups) { if (this.props.withGroups) {
const group = composeGroupsFromSettingsTab(this.tab.settings) const group = composeGroupsFromSettingsTab(tab.settings)
return <> return <>
{ {
@ -98,9 +99,11 @@ export default class SettingTab extends React.Component {
<div className="settings_list"> <div className="settings_list">
{ {
settings.map((setting) => <SettingItemComponent settings.map((setting, index) => <SettingItemComponent
key={index}
setting={setting} setting={setting}
ctx={this.state.processedCtx} ctx={ctx}
onUpdate={(value) => this.handleSettingUpdate(setting.id, value)}
/>) />)
} }
</div> </div>
@ -109,8 +112,8 @@ export default class SettingTab extends React.Component {
} }
{ {
this.tab.footer && React.createElement(this.tab.footer, { tab.footer && React.createElement(tab.footer, {
ctx: this.state.processedCtx ctx: this.state.ctx
}) })
} }
</> </>
@ -118,18 +121,22 @@ export default class SettingTab extends React.Component {
return <> return <>
{ {
this.tab.settings.map((setting, index) => { tab.settings.map((setting, index) => {
return <SettingItemComponent return <SettingItemComponent
key={index} key={index}
setting={setting} setting={setting}
ctx={this.state.processedCtx} ctx={{
...this.state.ctx,
baseConfig: this.props.baseConfig,
}}
onUpdate={(value) => this.handleSettingUpdate(setting.id, value)}
/> />
}) })
} }
{ {
this.tab.footer && React.createElement(this.tab.footer, { tab.footer && React.createElement(tab.footer, {
ctx: this.state.processedCtx ctx: this.state.ctx
}) })
} }
</> </>

View File

@ -1,11 +1,11 @@
import React from "react" import React from "react"
import * as antd from "antd" import * as antd from "antd"
import { Translation } from "react-i18next"
import classnames from "classnames"
import config from "config"
import useUrlQueryActiveKey from "hooks/useUrlQueryActiveKey"
import { createIconRender } from "components/Icons" import { createIconRender } from "components/Icons"
import { Translation } from "react-i18next"
import config from "config"
import useUrlQueryActiveKey from "hooks/useUrlQueryActiveKey"
import useUserRemoteConfig from "hooks/useUserRemoteConfig"
import { import {
composedSettingsByGroups as settings composedSettingsByGroups as settings
@ -88,6 +88,7 @@ const generateMenuItems = () => {
} }
export default () => { export default () => {
const [config, setConfig, loading] = useUserRemoteConfig()
const [activeKey, setActiveKey] = useUrlQueryActiveKey({ const [activeKey, setActiveKey] = useUrlQueryActiveKey({
defaultKey: "general", defaultKey: "general",
queryKey: "tab" queryKey: "tab"
@ -113,11 +114,14 @@ export default () => {
return items return items
}, []) }, [])
return <div function handleOnUpdate(key, value) {
className={classnames( setConfig({
"settings_wrapper", ...config,
)} [key]: value
> })
}
return <div className="settings_wrapper">
<div className="settings_menu"> <div className="settings_menu">
<antd.Menu <antd.Menu
mode="vertical" mode="vertical"
@ -128,10 +132,17 @@ export default () => {
</div> </div>
<div className="settings_content"> <div className="settings_content">
<SettingTab {
activeKey={activeKey} loading && <antd.Skeleton active />
withGroups }
/> {
!loading && <SettingTab
baseConfig={config}
onUpdate={handleOnUpdate}
activeKey={activeKey}
withGroups
/>
}
</div> </div>
</div> </div>
} }

View File

@ -95,6 +95,11 @@
padding: 0 20px; padding: 0 20px;
.uploadButton{
background-color: var(--background-color-primary);
border: 1px solid var(--border-color);
}
.setting_item_header { .setting_item_header {
display: inline-flex; display: inline-flex;
flex-direction: row; flex-direction: row;

View File

@ -299,23 +299,100 @@
} }
} }
.ant-notification-notice { .ant-notification {
background-color: var(--background-color-primary) !important; display: flex;
flex-direction: column;
gap: 20px;
.ant-notification-notice-wrapper {
margin: 0;
overflow: hidden;
border-radius: 12px;
outline: 1px solid var(--border-color) !important;
background-color: var(--background-color-primary) !important;
h1,
h2,
h3,
h4,
h5,
h6,
p,
span {
color: var(--text-color) !important;
} }
.ant-notification-notice-message, .ant-notification-notice {
.ant-notification-notice-description { display: flex;
color: var(--text-color) !important; flex-direction: column;
justify-content: center;
background-color: transparent !important;
h1,
h2,
h3,
h4,
h5,
h6,
p,
span {
color: var(--text-color) !important;
}
.ant-notification-notice-close-x {
svg {
margin: 0 !important;
}
}
.ant-notification-notice-with-icon {
display: flex;
flex-direction: column;
gap: 10px;
.ant-notification-notice-message {
margin: 0 !important;
}
}
.ant-notification-notice-message,
.ant-notification-notice-description {
color: var(--text-color) !important;
}
.ant-notification-notice-description {
display: none;
}
.ant-notification-notice-description:not(:empty) {
display: flex;
}
.ant-notification-notice-with-icon {
.ant-notification-notice-message {
margin-inline-start: 46px !important;
}
.ant-notification-notice-description {
margin-inline-start: 46px !important;
}
.ant-notification-notice-icon {
&.ant-notification-notice-icon-success {
color: #52c41a !important;
}
max-width: 40px;
svg {
margin: 0 !important;
}
img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
}
}
}
} }
} }
@ -334,6 +411,7 @@
} }
} }
.ant-message-error { .ant-message-error {
svg { svg {
color: var(--ant-error-color) !important; color: var(--ant-error-color) !important;
@ -518,30 +596,6 @@
z-index: 1000; z-index: 1000;
} }
.ant-notification-notice {
.ant-notification-notice-with-icon {
.ant-notification-notice-message {
margin-inline-start: 46px !important;
}
.ant-notification-notice-description {
margin-inline-start: 46px !important;
}
.ant-notification-notice-icon {
max-width: 40px;
img {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 8px;
}
}
}
}
.adm-action-sheet { .adm-action-sheet {
.adm-action-sheet-button-item-wrapper { .adm-action-sheet-button-item-wrapper {
border-bottom-color: var(--border-color); border-bottom-color: var(--border-color);

View File

@ -0,0 +1,180 @@
// Orginal forked from: Buzut/huge-uploader-nodejs
// Copyright (c) 2018, Quentin Busuttil All rights reserved.
import fs from "node:fs"
import path from "node:path"
import mimetypes from "mime-types"
export function checkTotalSize(
chunkSize, // in bytes
totalChunks, // number of chunks
maxFileSize, // in bytes
) {
const totalSize = chunkSize * totalChunks
if (totalSize > maxFileSize) {
return false
}
return true
}
export function checkChunkUploadHeaders(headers) {
if (
!headers["uploader-chunk-number"] ||
!headers["uploader-chunks-total"] ||
!headers["uploader-original-name"] ||
!headers["uploader-file-id"] ||
!headers["uploader-chunks-total"].match(/^[0-9]+$/) ||
!headers["uploader-chunk-number"].match(/^[0-9]+$/)
) {
return false
}
return true
}
export function createAssembleChunksPromise({
chunksPath, // chunks to assemble
filePath, // final assembled file path
maxFileSize,
}) {
return () => new Promise(async (resolve, reject) => {
let fileSize = 0
const chunks = await fs.promises.readdir(chunksPath)
if (chunks.length === 0) {
throw new Error("No chunks found")
}
for await (const chunk of chunks) {
const chunkPath = path.join(chunksPath, chunk)
const data = await fs.promises.readFile(chunkPath)
fileSize += data.length
// check if final file gonna exceed max file size
// in case early estimation is wrong (due client send bad headers)
if (fileSize > maxFileSize) {
return reject(new OperationError(413, "File exceeds max total file size, aborting assembly..."))
}
await fs.promises.appendFile(filePath, data)
continue
}
return resolve({
chunksLength: chunks.length,
filePath: filePath,
})
})
}
export async function handleChunkFile(fileStream, { tmpDir, headers, maxFileSize, maxChunkSize }) {
return await new Promise(async (resolve, reject) => {
const chunksPath = path.join(tmpDir, headers["uploader-file-id"], "chunks")
const chunkPath = path.join(chunksPath, headers["uploader-chunk-number"])
const chunkCount = +headers["uploader-chunk-number"]
const totalChunks = +headers["uploader-chunks-total"]
// check if file has all chunks uploaded
const isLast = chunkCount === totalChunks - 1
// make sure chunk is in range
if (chunkCount < 0 || chunkCount >= totalChunks) {
throw new Error("Chunk is out of range")
}
// if is the first chunk check if dir exists before write things
if (chunkCount === 0) {
if (!await fs.promises.stat(chunksPath).catch(() => false)) {
await fs.promises.mkdir(chunksPath, { recursive: true })
}
}
let dataWritten = 0
let writeStream = fs.createWriteStream(chunkPath)
writeStream.on("error", (err) => {
reject(err)
})
writeStream.on("close", () => {
if (maxChunkSize !== undefined) {
if (dataWritten > maxChunkSize) {
reject(new OperationError(413, "Chunk size exceeds max chunk size, aborting upload..."))
return
}
// estimate total file size,
// if estimation exceeds maxFileSize, abort upload
if (chunkCount === 0 && totalChunks > 0) {
if ((dataWritten * (totalChunks - 1)) > maxFileSize) {
reject(new OperationError(413, "File estimated size exceeds max total file size, aborting upload..."))
return
}
}
}
if (isLast) {
const mimetype = mimetypes.lookup(headers["uploader-original-name"])
const extension = mimetypes.extension(mimetype)
let filename = headers["uploader-file-id"]
if (headers["uploader-use-date"] === "true") {
filename = `${filename}_${Date.now()}`
}
return resolve(createAssembleChunksPromise({
// build data
chunksPath: chunksPath,
filePath: path.resolve(chunksPath, `${filename}.${extension}`),
maxFileSize: maxFileSize,
}))
}
return resolve(null)
})
fileStream.on("data", (buffer) => {
dataWritten += buffer.byteLength
})
fileStream.pipe(writeStream)
})
}
export async function uploadChunkFile(req, {
tmpDir,
maxFileSize,
maxChunkSize,
}) {
return await new Promise(async (resolve, reject) => {
if (!checkChunkUploadHeaders(req.headers)) {
reject(new OperationErrorError(400, "Missing header(s)"))
return
}
await req.multipart(async (field) => {
try {
const result = await handleChunkFile(field.file.stream, {
tmpDir: tmpDir,
headers: req.headers,
maxFileSize: maxFileSize,
maxChunkSize: maxChunkSize,
})
return resolve(result)
} catch (error) {
return reject(error)
}
})
})
}
export default uploadChunkFile

View File

@ -1,260 +0,0 @@
// Orginal forked from: Buzut/huge-uploader-nodejs
// Copyright (c) 2018, Quentin Busuttil All rights reserved.
import fs from "node:fs"
import path from "node:path"
import { promisify } from "node:util"
import mimetypes from "mime-types"
import crypto from "node:crypto"
import Busboy from "busboy"
export function getFileHash(file) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash("sha256")
file.on("data", (chunk) => hash.update(chunk))
file.on("end", () => resolve(hash.digest("hex")))
file.on("error", reject)
})
}
export function checkHeaders(headers) {
if (
!headers["uploader-chunk-number"] ||
!headers["uploader-chunks-total"] ||
!headers["uploader-original-name"] ||
!headers["uploader-file-id"] ||
!headers["uploader-chunks-total"].match(/^[0-9]+$/) ||
!headers["uploader-chunk-number"].match(/^[0-9]+$/)
) {
return false
}
return true
}
export function checkTotalSize(maxFileSize, maxChunkSize, totalChunks) {
if (maxChunkSize * totalChunks > maxFileSize) {
return false
}
return true
}
export function cleanChunks(dirPath) {
fs.readdir(dirPath, (err, files) => {
let filesLength = files.length
files.forEach((file) => {
fs.unlink(path.join(dirPath, file), () => {
if (--filesLength === 0) fs.rmdir(dirPath, () => { }) // cb does nothing but required
})
})
})
}
export function createAssembleChunksPromise({
tmpDir,
headers,
useDate,
}) {
const asyncReadFile = promisify(fs.readFile)
const asyncAppendFile = promisify(fs.appendFile)
const originalMimeType = mimetypes.lookup(headers["uploader-original-name"])
const originalExtension = mimetypes.extension(originalMimeType)
const totalChunks = +headers["uploader-chunks-total"]
const fileId = headers["uploader-file-id"]
const workPath = path.join(tmpDir, fileId)
const chunksPath = path.resolve(workPath, "chunks")
const assembledFilepath = path.join(workPath, `assembled.${originalExtension}`)
let chunkCount = 0
let finalFilepath = null
return () => {
return new Promise((resolve, reject) => {
const onEnd = async () => {
try {
const hash = await getFileHash(fs.createReadStream(assembledFilepath))
if (useDate) {
finalFilepath = path.resolve(workPath, `${hash}_${Date.now()}.${originalExtension}`)
} else {
finalFilepath = path.resolve(workPath, `${hash}.${originalExtension}`)
}
fs.renameSync(assembledFilepath, finalFilepath)
cleanChunks(chunksPath)
return resolve({
filename: headers["uploader-original-name"],
filepath: finalFilepath,
cachePath: workPath,
hash,
mimetype: originalMimeType,
extension: originalExtension,
})
} catch (error) {
return reject(error)
}
}
const pipeChunk = () => {
asyncReadFile(path.join(chunksPath, chunkCount.toString()))
.then((chunk) => asyncAppendFile(assembledFilepath, chunk))
.then(() => {
// 0 indexed files = length - 1, so increment before comparison
if (totalChunks > ++chunkCount) {
return pipeChunk(chunkCount)
}
return onEnd()
})
.catch(reject)
}
pipeChunk()
})
}
}
export function mkdirIfDoesntExist(dirPath, callback) {
if (!fs.existsSync(dirPath)) {
fs.mkdir(dirPath, { recursive: true }, callback)
}
}
export function handleFile(tmpDir, headers, fileStream) {
const dirPath = path.join(tmpDir, headers["uploader-file-id"])
const chunksPath = path.join(dirPath, "chunks")
const chunkPath = path.join(chunksPath, headers["uploader-chunk-number"])
const useDate = headers["uploader-use-date"] === "true"
const chunkCount = +headers["uploader-chunk-number"]
const totalChunks = +headers["uploader-chunks-total"]
let error
let assembleChunksPromise
let finished = false
let writeStream
const writeFile = () => {
writeStream = fs.createWriteStream(chunkPath)
writeStream.on("error", (err) => {
error = err
fileStream.resume()
})
writeStream.on("close", () => {
finished = true
// if all is uploaded
if (chunkCount === totalChunks - 1) {
assembleChunksPromise = createAssembleChunksPromise({
tmpDir,
headers,
useDate,
})
}
})
fileStream.pipe(writeStream)
}
// make sure chunk is in range
if (chunkCount < 0 || chunkCount >= totalChunks) {
error = new Error("Chunk is out of range")
fileStream.resume()
}
else if (chunkCount === 0) {
// create file upload dir if it's first chunk
mkdirIfDoesntExist(chunksPath, (err) => {
if (err) {
error = err
fileStream.resume()
}
else writeFile()
})
}
else {
// make sure dir exists if it's not first chunk
fs.stat(dirPath, (err) => {
if (err) {
error = new Error("Upload has expired")
fileStream.resume()
}
else writeFile()
})
}
return (callback) => {
if (finished && !error) callback(null, assembleChunksPromise)
else if (error) callback(error)
else {
writeStream.on("error", callback)
writeStream.on("close", () => callback(null, assembleChunksPromise))
}
}
}
export function uploadFile(req, tmpDir, maxFileSize, maxChunkSize) {
return new Promise((resolve, reject) => {
if (!checkHeaders(req.headers)) {
reject(new Error("Missing header(s)"))
return
}
if (!checkTotalSize(maxFileSize, req.headers["uploader-chunks-total"])) {
reject(new Error("File is above size limit"))
return
}
try {
let limitReached = false
let getFileStatus
const busboy = Busboy({ headers: req.headers, limits: { files: 1, fileSize: maxChunkSize * 1000 * 1000 } })
busboy.on("file", (fieldname, fileStream) => {
fileStream.on("limit", () => {
limitReached = true
fileStream.resume()
})
getFileStatus = handleFile(tmpDir, req.headers, fileStream)
})
busboy.on("close", () => {
if (limitReached) {
reject(new Error("Chunk is above size limit"))
return
}
getFileStatus((fileErr, assembleChunksF) => {
if (fileErr) reject(fileErr)
else resolve(assembleChunksF)
})
})
req.pipe(busboy)
}
catch (err) {
reject(err)
}
})
}
export default uploadFile

View File

@ -0,0 +1,40 @@
import { Config } from "@db_models"
export default class Limits {
static async get(key) {
const { value } = await Config.findOne({
key: "limits"
}).catch(() => {
return {
value: {}
}
})
const limits = {
maxChunkSizeInMB: 5,
maxFileSizeInMB: 8,
maxNumberOfFiles: 10,
maxPostCharacters: 2000,
maxAccountsPerIp: 10,
...value,
}
if (typeof key === "string") {
return {
value: limits[key] ?? null
}
}
if (Array.isArray(key)) {
const result = {}
key.forEach((k) => {
result[k] = limits[k] ?? null
})
return result
}
return limits
}
}

View File

@ -8,5 +8,6 @@ export default {
attachments: { type: Array, default: [] }, attachments: { type: Array, default: [] },
flags: { type: Array, default: [] }, flags: { type: Array, default: [] },
reply_to: { type: String, default: null }, reply_to: { type: String, default: null },
updated_at: { type: String, default: null },
} }
} }

View File

@ -1,6 +1,6 @@
export default { export default {
name: "SavedPost", name: "PostSave",
collection: "savedPosts", collection: "post_saves",
schema: { schema: {
post_id: { post_id: {
type: "string", type: "string",

View File

@ -15,6 +15,6 @@ export default {
badges: { type: Array, default: [] }, badges: { type: Array, default: [] },
links: { type: Array, default: [] }, links: { type: Array, default: [] },
location: { type: String, default: null }, location: { type: String, default: null },
birthday: { type: Date, default: null }, birthday: { type: Date, default: null, select: false },
} }
} }

View File

@ -10,18 +10,16 @@ import chalk from "chalk"
import Spinnies from "spinnies" import Spinnies from "spinnies"
import chokidar from "chokidar" import chokidar from "chokidar"
import IPCRouter from "linebridge/src/server/classes/IPCRouter" import IPCRouter from "linebridge/src/server/classes/IPCRouter"
import fastify from "fastify" import treeKill from "tree-kill"
import { createProxyMiddleware } from "http-proxy-middleware"
import { dots as DefaultSpinner } from "spinnies/spinners.json" import { dots as DefaultSpinner } from "spinnies/spinners.json"
import getInternalIp from "./lib/getInternalIp" import getInternalIp from "./lib/getInternalIp"
import comtyAscii from "./ascii" import comtyAscii from "./ascii"
import pkg from "./package.json" import pkg from "./package.json"
import cors from "linebridge/src/server/middlewares/cors"
import { onExit } from "signal-exit" import { onExit } from "signal-exit"
import Proxy from "./proxy"
const bootloaderBin = path.resolve(__dirname, "boot") const bootloaderBin = path.resolve(__dirname, "boot")
const servicesPath = path.resolve(__dirname, "services") const servicesPath = path.resolve(__dirname, "services")
@ -51,7 +49,7 @@ async function scanServices() {
return finalServices return finalServices
} }
let internal_proxy = null let internal_proxy = new Proxy()
let allReady = false let allReady = false
let selectedProcessInstance = null let selectedProcessInstance = null
let internalIp = null let internalIp = null
@ -72,7 +70,7 @@ Observable.observe(serviceRegistry, (changes) => {
//console.log(`Updated service | ${path} > ${value}`) //console.log(`Updated service | ${path} > ${value}`)
//check if all services all ready //check if all services all ready
if (Object.values(serviceRegistry).every((service) => service.ready)) { if (Object.values(serviceRegistry).every((service) => service.initialized)) {
handleAllReady() handleAllReady()
} }
@ -176,6 +174,8 @@ async function handleAllReady() {
console.log(comtyAscii) console.log(comtyAscii)
console.log(`🎉 All services[${services.length}] ready!\n`) console.log(`🎉 All services[${services.length}] ready!\n`)
console.log(`USE: select <service>, reboot, exit`) console.log(`USE: select <service>, reboot, exit`)
await internal_proxy.listen(9000, "0.0.0.0")
} }
// SERVICE WATCHER FUNCTIONS // SERVICE WATCHER FUNCTIONS
@ -189,6 +189,8 @@ async function handleNewServiceStarting(id) {
} }
async function handleServiceStarted(id) { async function handleServiceStarted(id) {
serviceRegistry[id].initialized = true
if (serviceRegistry[id].ready === false) { if (serviceRegistry[id].ready === false) {
if (spinnies.pick(id)) { if (spinnies.pick(id)) {
spinnies.succeed(id, { text: `[${id}][${serviceRegistry[id].index}] Ready` }) spinnies.succeed(id, { text: `[${id}][${serviceRegistry[id].index}] Ready` })
@ -199,7 +201,7 @@ async function handleServiceStarted(id) {
} }
async function handleServiceExit(id, code, err) { async function handleServiceExit(id, code, err) {
//console.log(`🛑 Service ${id} exited with code ${code}`, err) serviceRegistry[id].initialized = true
if (serviceRegistry[id].ready === false) { if (serviceRegistry[id].ready === false) {
if (spinnies.pick(id)) { if (spinnies.pick(id)) {
@ -207,29 +209,14 @@ async function handleServiceExit(id, code, err) {
} }
} }
console.log(`[${id}] Exit with code ${code}`)
// try to unregister from proxy
internal_proxy.unregisterAllFromService(id)
serviceRegistry[id].ready = false serviceRegistry[id].ready = false
} }
async function registerProxy(_path, target, pathRewrite) {
if (internal_proxy.proxys.has(_path)) {
console.warn(`Proxy already registered [${_path}], skipping...`)
return false
}
console.log(`🔗 Registering path proxy [${_path}] -> [${target}]`)
internal_proxy.proxys.add(_path)
internal_proxy.use(_path, createProxyMiddleware({
target: target,
changeOrigin: true,
pathRewrite: pathRewrite,
ws: true,
logLevel: "silent",
}))
return true
}
async function handleIPCData(service_id, msg) { async function handleIPCData(service_id, msg) {
if (msg.type === "log") { if (msg.type === "log") {
@ -243,21 +230,35 @@ async function handleIPCData(service_id, msg) {
if (msg.type === "router:register") { if (msg.type === "router:register") {
if (msg.data.path_overrides) { if (msg.data.path_overrides) {
for await (let pathOverride of msg.data.path_overrides) { for await (let pathOverride of msg.data.path_overrides) {
await registerProxy( await internal_proxy.register({
`/${pathOverride}`, serviceId: service_id,
`http://${internalIp}:${msg.data.listen.port}/${pathOverride}`, path: `/${pathOverride}`,
{ target: `http://${internalIp}:${msg.data.listen.port}/${pathOverride}`,
pathRewrite: {
[`^/${pathOverride}`]: "", [`^/${pathOverride}`]: "",
} },
) })
} }
} else { } else {
await registerProxy( await internal_proxy.register({
`/${service_id}`, serviceId: service_id,
`http://${msg.data.listen.ip}:${msg.data.listen.port}` path: `/${service_id}`,
) target: `http://${msg.data.listen.ip}:${msg.data.listen.port}`,
})
} }
} }
if (msg.type === "router:ws:register") {
await internal_proxy.register({
serviceId: service_id,
path: `/${msg.data.namespace}`,
target: `http://${internalIp}:${msg.data.listen.port}/${msg.data.namespace}`,
pathRewrite: {
[`^/${msg.data.namespace}`]: "",
},
ws: true,
})
}
} }
function spawnService({ id, service, cwd }) { function spawnService({ id, service, cwd }) {
@ -276,11 +277,15 @@ function spawnService({ id, service, cwd }) {
silent: true, silent: true,
cwd: cwd, cwd: cwd,
env: instanceEnv, env: instanceEnv,
killSignal: "SIGKILL",
}) })
instance.reload = () => { instance.reload = () => {
ipcRouter.unregister({ id, instance }) ipcRouter.unregister({ id, instance })
// try to unregister from proxy
internal_proxy.unregisterAllFromService(id)
instance.kill() instance.kill()
instance = spawnService({ id, service, cwd }) instance = spawnService({ id, service, cwd })
@ -340,31 +345,6 @@ function createServiceLogTransformer({ id, color = "bgCyan" }) {
async function main() { async function main() {
internalIp = await getInternalIp() internalIp = await getInternalIp()
internal_proxy = fastify()
internal_proxy.proxys = new Set()
await internal_proxy.register(require("@fastify/middie"))
await internal_proxy.use(cors)
internal_proxy.get("/ping", (request, reply) => {
return reply.send({
status: "ok"
})
})
internal_proxy.get("/", (request, reply) => {
return reply.send({
services: instancePool.map((instance) => {
return {
id: instance.id,
version: instance.version,
}
}),
})
})
console.clear() console.clear()
console.log(comtyAscii) console.log(comtyAscii)
console.log(`\nRunning ${chalk.bgBlue(`${pkg.name}`)} | ${chalk.bgMagenta(`[v${pkg.version}]`)} | ${internalIp} \n\n\n`) console.log(`\nRunning ${chalk.bgBlue(`${pkg.name}`)} | ${chalk.bgMagenta(`[v${pkg.version}]`)} | ${internalIp} \n\n\n`)
@ -417,6 +397,7 @@ async function main() {
if (process.env.NODE_ENV === "development") { if (process.env.NODE_ENV === "development") {
const ignored = [ const ignored = [
...await getIgnoredFiles(cwd), ...await getIgnoredFiles(cwd),
"**/.cache/**",
"**/node_modules/**", "**/node_modules/**",
"**/dist/**", "**/dist/**",
"**/build/**", "**/build/**",
@ -438,7 +419,6 @@ async function main() {
} }
} }
// create repl
repl.start({ repl.start({
prompt: "> ", prompt: "> ",
useGlobal: true, useGlobal: true,
@ -474,11 +454,6 @@ async function main() {
} }
}) })
await internal_proxy.listen({
host: "0.0.0.0",
port: 9000
})
onExit((code, signal) => { onExit((code, signal) => {
console.clear() console.clear()
console.log(`\n🛑 Preparing to exit...`) console.log(`\n🛑 Preparing to exit...`)
@ -493,7 +468,11 @@ async function main() {
console.log(`Killing ${instance.id} [${instance.instance.pid}]`) console.log(`Killing ${instance.id} [${instance.instance.pid}]`)
instance.instance.kill() instance.instance.kill()
treeKill(instance.instance.pid)
} }
treeKill(process.pid)
}) })
} }

View File

@ -24,14 +24,21 @@
"clui": "^0.3.6", "clui": "^0.3.6",
"dotenv": "^16.4.4", "dotenv": "^16.4.4",
"fastify": "^4.26.2", "fastify": "^4.26.2",
"http-proxy-middleware": "^2.0.6", "http-proxy": "^1.18.1",
"http-proxy-middleware": "^3.0.0-beta.1",
"hyper-express": "^6.14.12",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"linebridge": "^0.18.1", "linebridge": "^0.18.1",
"module-alias": "^2.2.3", "module-alias": "^2.2.3",
"p-map": "^4.0.0", "p-map": "^4.0.0",
"p-queue": "^7.3.4", "p-queue": "^7.3.4",
"radix3": "^1.1.1",
"signal-exit": "^4.1.0", "signal-exit": "^4.1.0",
"spinnies": "^0.5.1" "spinnies": "^0.5.1",
"tree-kill": "^1.2.2",
"uWebSockets.js": "uNetworking/uWebSockets.js#v20.41.0",
"uws-reverse-proxy": "^3.2.1",
"yume-server": "^0.0.5"
}, },
"devDependencies": { "devDependencies": {
"chai": "^5.1.0", "chai": "^5.1.0",

188
packages/server/proxy.js Normal file
View File

@ -0,0 +1,188 @@
import http from "node:http"
import httpProxy from "http-proxy"
import defaults from "linebridge/src/server/defaults"
import pkg from "./package.json"
export default class Proxy {
constructor() {
this.proxys = new Map()
this.wsProxys = new Map()
this.http = http.createServer(this.handleHttpRequest)
this.http.on("upgrade", this.handleHttpUpgrade)
}
http = null
register = ({ serviceId, path, target, pathRewrite, ws } = {}) => {
if (!path) {
throw new Error("Path is required")
}
if (!target) {
throw new Error("Target is required")
}
if (this.proxys.has(path)) {
console.warn(`Proxy already registered [${path}], skipping...`)
return false
}
const proxy = httpProxy.createProxyServer({
target: target,
})
proxy.on("error", (e) => {
console.error(e)
})
const proxyObj = {
serviceId: serviceId ?? "default_service",
path: path,
target: target,
pathRewrite: pathRewrite,
proxy: proxy,
}
if (ws) {
console.log(`🔗 Registering websocket proxy [${path}] -> [${target}]`)
this.wsProxys.set(path, proxyObj)
} else {
console.log(`🔗 Registering path proxy [${path}] -> [${target}]`)
this.proxys.set(path, proxyObj)
}
return true
}
unregister = (path) => {
if (!this.proxys.has(path)) {
console.warn(`Proxy not registered [${path}], skipping...`)
return false
}
console.log(`🔗 Unregistering path proxy [${path}]`)
this.proxys.get(path).proxy.close()
this.proxys.delete(path)
}
unregisterAllFromService = (serviceId) => {
this.proxys.forEach((value, key) => {
if (value.serviceId === serviceId) {
this.unregister(value.path)
}
})
}
listen = async (port = 9000, host = "0.0.0.0", cb) => {
return await new Promise((resolve, reject) => {
this.http.listen(port, host, () => {
console.log(`🔗 Proxy listening on ${host}:${port}`)
if (cb) {
cb(this)
}
resolve(this)
})
})
}
rewritePath = (rewriteConfig, path) => {
let result = path
const rules = []
for (const [key, value] of Object.entries(rewriteConfig)) {
rules.push({
regex: new RegExp(key),
value: value,
})
}
for (const rule of rules) {
if (rule.regex.test(path)) {
result = result.replace(rule.regex, rule.value)
break
}
}
return result
}
setCorsHeaders = (res) => {
res.setHeader("Access-Control-Allow-Origin", "*")
res.setHeader("Access-Control-Allow-Methods", "GET,HEAD,PUT,PATCH,POST,DELETE")
res.setHeader("Access-Control-Allow-Headers", "*")
return res
}
handleHttpRequest = (req, res) => {
res = this.setCorsHeaders(res)
const sanitizedUrl = req.url.split("?")[0]
// preflight continue with code 204
if (req.method === "OPTIONS") {
res.statusCode = 204
res.end()
return
}
if (sanitizedUrl === "/") {
return res.end(`
{
"name": "${pkg.name}",
"version": "${pkg.version}",
"lb_version": "${defaults.version}"
}
`)
}
const namespace = `/${sanitizedUrl.split("/")[1]}`
const route = this.proxys.get(namespace)
if (!route) {
res.statusCode = 404
res.end(`
{
"error": "404 Not found"
}
`)
return
}
if (route.pathRewrite) {
req.url = this.rewritePath(route.pathRewrite, req.url)
}
//console.log(`HTTP REQUEST :`, req.url)
route.proxy.web(req, res)
}
handleHttpUpgrade = (req, socket, head) => {
const namespace = `/${req.url.split("/")[1]}`
const route = this.wsProxys.get(namespace)
if (!route) {
// destroy socket
socket.destroy()
return false
}
if (route.pathRewrite) {
req.url = this.rewritePath(route.pathRewrite, req.url)
}
//console.log(`HTTP UPGRADING :`, req.url)
route.proxy.ws(req, socket, head)
}
close = () => {
this.http.close()
}
}

View File

@ -18,7 +18,7 @@ export default async (req, res) => {
}) })
if (userConfig && userConfig.values) { if (userConfig && userConfig.values) {
if (userConfig.values.mfa_enabled) { if (userConfig.values["auth:mfa"]) {
let codeVerified = false let codeVerified = false
// search if is already a mfa session // search if is already a mfa session

View File

@ -1,20 +0,0 @@
import path from "path"
import createRoutesFromDirectory from "@utils/createRoutesFromDirectory"
import getMiddlewares from "@utils/getMiddlewares"
export default async (router) => {
const routesPath = path.resolve(__dirname, "routes")
const middlewares = await getMiddlewares(["withOptionalAuth"])
for (const middleware of middlewares) {
router.use(middleware)
}
router = createRoutesFromDirectory("routes", routesPath, router)
return {
path: "/stream",
router,
}
}

View File

@ -1,24 +0,0 @@
import { NotFoundError, InternalServerError } from "@shared-classes/Errors"
import mimetypes from "mime-types"
export default async (req, res) => {
const streamPath = req.params[0]
global.storage.getObject(process.env.S3_BUCKET, streamPath, (err, dataStream) => {
if (err) {
console.error(err)
return new InternalServerError(req, res, "Error while getting file from storage")
}
const extname = mimetypes.lookup(streamPath)
// send chunked response
res.status(200)
// set headers
res.setHeader("Content-Type", extname)
res.setHeader("Accept-Ranges", "bytes")
return dataStream.pipe(res)
})
}

View File

@ -1,20 +0,0 @@
import path from "path"
import createRoutesFromDirectory from "@utils/createRoutesFromDirectory"
import getMiddlewares from "@utils/getMiddlewares"
export default async (router) => {
const routesPath = path.resolve(__dirname, "routes")
const middlewares = await getMiddlewares(["withOptionalAuth"])
for (const middleware of middlewares) {
router.use(middleware)
}
router = createRoutesFromDirectory("routes", routesPath, router)
return {
path: "/upload",
router,
}
}

View File

@ -1,107 +0,0 @@
import path from "path"
import fs from "fs"
import * as Errors from "@shared-classes/Errors"
import FileUpload from "@shared-classes/FileUpload"
import PostProcess from "@services/post-process"
const cachePath = global.cache.constructor.cachePath
export default async (req, res) => {
// extract authentification header
let auth = req.session
if (!auth) {
return new Errors.AuthorizationError(req, res)
}
const providerType = req.headers["provider-type"]
const userPath = path.join(cachePath, req.session.user_id)
// 10 GB in bytes
const maxFileSize = 10 * 1000 * 1000 * 1000
// 10MB in bytes
const maxChunkSize = 10 * 1000 * 1000
let build = await FileUpload(req, userPath, maxFileSize, maxChunkSize)
.catch((err) => {
console.log("err", err)
new Errors.InternalServerError(req, res, err.message)
return false
})
if (build === false) {
return false
} else {
if (typeof build === "function") {
try {
build = await build()
if (!req.headers["no-compression"]) {
build = await PostProcess(build)
}
// compose remote path
const remotePath = `${req.session.user_id}/${path.basename(build.filepath)}`
let url = null
switch (providerType) {
case "premium-cdn": {
// use backblaze b2
await global.b2Storage.authorize()
const uploadUrl = await global.b2Storage.getUploadUrl({
bucketId: process.env.B2_BUCKET_ID,
})
const data = await fs.promises.readFile(build.filepath)
await global.b2Storage.uploadFile({
uploadUrl: uploadUrl.data.uploadUrl,
uploadAuthToken: uploadUrl.data.authorizationToken,
fileName: remotePath,
data: data,
info: build.metadata
})
url = `https://${process.env.B2_CDN_ENDPOINT}/${process.env.B2_BUCKET}/${remotePath}`
break
}
default: {
// upload to storage
await global.storage.fPutObject(process.env.S3_BUCKET, remotePath, build.filepath, build.metadata ?? {
"Content-Type": build.mimetype,
})
// compose url
url = global.storage.composeRemoteURL(remotePath)
break
}
}
// remove from cache
fs.promises.rm(build.cachePath, { recursive: true, force: true })
return res.json({
name: build.filename,
id: remotePath,
url: url,
})
} catch (error) {
console.log(error)
return new Errors.InternalServerError(req, res, error.message)
}
}
return res.json({
success: true,
})
}
}

View File

@ -2,11 +2,13 @@ import { Server } from "linebridge/src/server"
import B2 from "backblaze-b2" import B2 from "backblaze-b2"
import DbManager from "@shared-classes/DbManager"
import RedisClient from "@shared-classes/RedisClient" import RedisClient from "@shared-classes/RedisClient"
import StorageClient from "@shared-classes/StorageClient" import StorageClient from "@shared-classes/StorageClient"
import CacheService from "@shared-classes/CacheService" import CacheService from "@shared-classes/CacheService"
import SharedMiddlewares from "@shared-middlewares" import SharedMiddlewares from "@shared-middlewares"
import LimitsClass from "@shared-classes/Limits"
class API extends Server { class API extends Server {
static refName = "files" static refName = "files"
@ -14,13 +16,12 @@ class API extends Server {
static routesPath = `${__dirname}/routes` static routesPath = `${__dirname}/routes`
static listen_port = process.env.HTTP_LISTEN_PORT ?? 3002 static listen_port = process.env.HTTP_LISTEN_PORT ?? 3002
static maxBodyLength = 1000 * 1000 * 1000
middlewares = { middlewares = {
...SharedMiddlewares ...SharedMiddlewares
} }
contexts = { contexts = {
db: new DbManager(),
cache: new CacheService(), cache: new CacheService(),
redis: RedisClient(), redis: RedisClient(),
storage: StorageClient(), storage: StorageClient(),
@ -28,12 +29,19 @@ class API extends Server {
applicationKeyId: process.env.B2_KEY_ID, applicationKeyId: process.env.B2_KEY_ID,
applicationKey: process.env.B2_APP_KEY, applicationKey: process.env.B2_APP_KEY,
}), }),
limits: {},
} }
async onInitialize() { async onInitialize() {
global.storage = this.contexts.storage
global.b2Storage = this.contexts.b2Storage
await this.contexts.db.initialize()
await this.contexts.redis.initialize() await this.contexts.redis.initialize()
await this.contexts.storage.initialize() await this.contexts.storage.initialize()
await this.contexts.b2Storage.authorize() await this.contexts.b2Storage.authorize()
this.contexts.limits = await LimitsClass.get()
} }
} }

View File

@ -1,104 +1,54 @@
import path from "path" import path from "path"
import fs from "fs" import fs from "fs"
import FileUpload from "@shared-classes/FileUpload" import ChunkFileUpload from "@shared-classes/ChunkFileUpload"
import PostProcess from "@services/post-process"
import RemoteUpload from "@services/remoteUpload"
export default { export default {
useContext: ["cache", "storage", "b2Storage"], useContext: ["cache", "limits"],
middlewares: [ middlewares: [
"withAuthentication", "withAuthentication",
], ],
fn: async (req, res) => { fn: async (req, res) => {
const { cache, storage, b2Storage } = this.default.contexts
const providerType = req.headers["provider-type"] const providerType = req.headers["provider-type"]
const userPath = path.join(cache.constructor.cachePath, req.session.user_id) const userPath = path.join(this.default.contexts.cache.constructor.cachePath, req.auth.session.user_id)
// 10 GB in bytes const tmpPath = path.resolve(userPath)
const maxFileSize = 10 * 1000 * 1000 * 1000
// 10MB in bytes let build = await ChunkFileUpload(req, {
const maxChunkSize = 10 * 1000 * 1000 tmpDir: tmpPath,
maxFileSize: parseInt(this.default.contexts.limits.maxFileSizeInMB) * 1024 * 1024,
maxChunkSize: parseInt(this.default.contexts.limits.maxChunkSizeInMB) * 1024 * 1024,
}).catch((err) => {
throw new OperationError(err.code, err.message)
})
let build = await FileUpload(req, userPath, maxFileSize, maxChunkSize) if (typeof build === "function") {
.catch((err) => { try {
console.log("err", err) build = await build()
throw new OperationError(500, err.message) const result = await RemoteUpload({
}) parentDir: req.auth.session.user_id,
source: build.filePath,
service: providerType,
useCompression: req.headers["use-compression"] ?? true,
cachePath: tmpPath,
})
if (build === false) { fs.promises.rm(tmpPath, { recursive: true, force: true })
return false
} else {
if (typeof build === "function") {
try {
build = await build()
if (!req.headers["no-compression"]) { return result
build = await PostProcess(build) } catch (error) {
} fs.promises.rm(tmpPath, { recursive: true, force: true })
// compose remote path throw new OperationError(error.code ?? 500, error.message ?? "Failed to upload file")
const remotePath = `${req.session.user_id}/${path.basename(build.filepath)}`
let url = null
switch (providerType) {
case "premium-cdn": {
// use backblaze b2
await b2Storage.authorize()
const uploadUrl = await b2Storage.getUploadUrl({
bucketId: process.env.B2_BUCKET_ID,
})
const data = await fs.promises.readFile(build.filepath)
await b2Storage.uploadFile({
uploadUrl: uploadUrl.data.uploadUrl,
uploadAuthToken: uploadUrl.data.authorizationToken,
fileName: remotePath,
data: data,
info: build.metadata
})
url = `https://${process.env.B2_CDN_ENDPOINT}/${process.env.B2_BUCKET}/${remotePath}`
break
}
default: {
// upload to storage
await storage.fPutObject(process.env.S3_BUCKET, remotePath, build.filepath, build.metadata ?? {
"Content-Type": build.mimetype,
})
// compose url
url = storage.composeRemoteURL(remotePath)
break
}
}
// remove from cache
fs.promises.rm(build.cachePath, { recursive: true, force: true })
return res.json({
name: build.filename,
id: remotePath,
url: url,
})
} catch (error) {
console.log(error)
throw new OperationError(500, error.message)
}
} }
}
return res.json({ return {
success: true, ok: 1
})
} }
} }
} }

View File

@ -0,0 +1,48 @@
import path from "node:path"
import fs from "node:fs"
import RemoteUpload from "@services/remoteUpload"
export default {
useContext: ["cache"],
middlewares: [
"withAuthentication",
],
fn: async (req, res) => {
const { cache } = this.default.contexts
const providerType = req.headers["provider-type"] ?? "standard"
const userPath = path.join(cache.constructor.cachePath, req.auth.session.user_id)
let localFilepath = null
let tmpPath = path.resolve(userPath, `${Date.now()}`)
await req.multipart(async (field) => {
if (!field.file) {
throw new OperationError(400, "Missing file")
}
localFilepath = path.join(tmpPath, field.file.name)
const existTmpDir = await fs.promises.stat(tmpPath).then(() => true).catch(() => false)
if (!existTmpDir) {
await fs.promises.mkdir(tmpPath, { recursive: true })
}
await field.write(localFilepath)
})
const result = await RemoteUpload({
parentDir: req.auth.session.user_id,
source: localFilepath,
service: providerType,
useCompression: req.headers["use-compression"] ?? true,
})
fs.promises.rm(tmpPath, { recursive: true, force: true })
return result
}
}

View File

@ -0,0 +1,6 @@
export default {
useContext: ["cache", "limits"],
fn: async () => {
return this.default.contexts.limits
}
}

View File

@ -29,11 +29,14 @@ async function processVideo(
videoBitrate = 2024, videoBitrate = 2024,
} = options } = options
const result = await videoTranscode(file.filepath, file.cachePath, { const result = await videoTranscode(file.filepath, {
videoCodec, videoCodec,
format, format,
audioBitrate, audioBitrate,
videoBitrate: [videoBitrate, true], videoBitrate: [videoBitrate, true],
extraOptions: [
"-threads 1"
]
}) })
file.filepath = result.filepath file.filepath = result.filepath

View File

@ -0,0 +1,123 @@
import fs from "node:fs"
import path from "node:path"
import mimeTypes from "mime-types"
import getFileHash from "@shared-utils/readFileHash"
import PostProcess from "../post-process"
export async function standardUpload({
source,
remotePath,
metadata,
}) {
// upload to storage
await global.storage.fPutObject(process.env.S3_BUCKET, remotePath, source, metadata)
// compose url
const url = storage.composeRemoteURL(remotePath)
return {
id: remotePath,
url: url,
metadata: metadata,
}
}
export async function b2Upload({
source,
remotePath,
metadata,
}) {
// use backblaze b2
await b2Storage.authorize()
const uploadUrl = await global.b2Storage.getUploadUrl({
bucketId: process.env.B2_BUCKET_ID,
})
const data = await fs.promises.readFile(source)
await global.b2Storage.uploadFile({
uploadUrl: uploadUrl.data.uploadUrl,
uploadAuthToken: uploadUrl.data.authorizationToken,
fileName: remotePath,
data: data,
info: metadata
})
const url = `https://${process.env.B2_CDN_ENDPOINT}/${process.env.B2_BUCKET}/${remotePath}`
return {
id: remotePath,
url: url,
metadata: metadata,
}
}
export default async ({
source,
parentDir,
service,
useCompression,
cachePath,
}) => {
if (!source) {
throw new OperationError(500, "source is required")
}
if (!service) {
service = "standard"
}
if (!parentDir) {
parentDir = "/"
}
if (useCompression) {
try {
const processOutput = await PostProcess({ filepath: source, cachePath })
if (processOutput) {
if (processOutput.filepath) {
source = processOutput.filepath
}
}
} catch (error) {
console.error(error)
throw new OperationError(500, `Failed to process file`)
}
}
const type = mimeTypes.lookup(path.basename(source))
const hash = await getFileHash(fs.createReadStream(source))
const remotePath = path.join(parentDir, hash)
let result = {}
const metadata = {
"Content-Type": type,
"File-Hash": hash,
}
switch (service) {
case "b2":
result = await b2Upload({
remotePath,
source,
metadata,
})
break
case "standard":
result = await standardUpload({
remotePath,
source,
metadata,
})
break
default:
throw new OperationError(500, "Unsupported service")
}
return result
}

View File

@ -10,11 +10,20 @@ const defaultParams = {
format: "webm", format: "webm",
} }
export default (input, cachePath, params = defaultParams) => { const maxTasks = 5
export default (input, params = defaultParams) => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const filename = path.basename(input) if (!global.ffmpegTasks) {
const outputFilename = `${filename.split(".")[0]}_ff.${params.format ?? "webm"}` global.ffmpegTasks = []
const outputFilepath = `${cachePath}/${outputFilename}` }
if (global.ffmpegTasks.length >= maxTasks) {
return reject(new Error("Too many transcoding tasks"))
}
const outputFilename = `${path.basename(input).split(".")[0]}_ff.${params.format ?? "webm"}`
const outputFilepath = `${path.dirname(input)}/${outputFilename}`
console.debug(`[TRANSCODING] Transcoding ${input} to ${outputFilepath}`) console.debug(`[TRANSCODING] Transcoding ${input} to ${outputFilepath}`)
@ -22,8 +31,8 @@ export default (input, cachePath, params = defaultParams) => {
console.debug(`[TRANSCODING] Finished transcode ${input} to ${outputFilepath}`) console.debug(`[TRANSCODING] Finished transcode ${input} to ${outputFilepath}`)
return resolve({ return resolve({
filepath: outputFilepath,
filename: outputFilename, filename: outputFilename,
filepath: outputFilepath,
}) })
} }
@ -42,22 +51,33 @@ export default (input, cachePath, params = defaultParams) => {
} }
// chain methods // chain methods
Object.keys(commands).forEach((key) => { for (let key in commands) {
if (exec === null) { if (exec === null) {
exec = ffmpeg(commands[key]) exec = ffmpeg(commands[key])
} else { continue
if (typeof exec[key] !== "function") { }
console.warn(`[TRANSCODING] Method ${key} is not a function`)
return false if (key === "extraOptions" && Array.isArray(commands[key])) {
for (const option of commands[key]) {
exec = exec.inputOptions(option)
} }
if (Array.isArray(commands[key])) { continue
exec = exec[key](...commands[key])
} else {
exec = exec[key](commands[key])
}
} }
})
if (typeof exec[key] !== "function") {
console.warn(`[TRANSCODING] Method ${key} is not a function`)
return false
}
if (Array.isArray(commands[key])) {
exec = exec[key](...commands[key])
} else {
exec = exec[key](commands[key])
}
continue
}
exec exec
.on("error", onError) .on("error", onError)

View File

@ -1,28 +1,16 @@
import { Server } from "linebridge/src/server" import { Server } from "linebridge/src/server"
import { Config, User } from "@db_models"
import DbManager from "@shared-classes/DbManager" import DbManager from "@shared-classes/DbManager"
import StorageClient from "@shared-classes/StorageClient"
import Token from "@lib/token" import StartupDB from "./startup_db"
import SharedMiddlewares from "@shared-middlewares" import SharedMiddlewares from "@shared-middlewares"
export default class API extends Server { export default class API extends Server {
static refName = "main" static refName = "main"
static useEngine = "hyper-express" static useEngine = "hyper-express"
static routesPath = `${__dirname}/routes`
static listen_port = process.env.HTTP_LISTEN_PORT || 3000 static listen_port = process.env.HTTP_LISTEN_PORT || 3000
static requireWSAuth = true
constructor(params) {
super(params)
global.DEFAULT_POSTING_POLICY = {
maxMessageLength: 512,
maximumFileSize: 80 * 1024 * 1024,
maximunFilesPerRequest: 20,
}
}
middlewares = { middlewares = {
...require("@middlewares").default, ...require("@middlewares").default,
@ -31,102 +19,16 @@ export default class API extends Server {
events = require("./events") events = require("./events")
storage = global.storage = StorageClient() contexts = {
DB = new DbManager() db: new DbManager(),
}
async onInitialize() { async onInitialize() {
await this.DB.initialize() await this.contexts.db.initialize()
await this.storage.initialize() await StartupDB()
await this.initializeConfigDB()
await this.checkSetup()
} }
initializeConfigDB = async () => { handleWsAuth = require("@shared-lib/handleWsAuth").default
let serverConfig = await Config.findOne({ key: "server" }).catch(() => {
return false
})
if (!serverConfig) {
serverConfig = new Config({
key: "server",
value: {
setup: false,
},
})
await serverConfig.save()
}
}
checkSetup = async () => {
return new Promise(async (resolve, reject) => {
let setupOk = (await Config.findOne({ key: "server" })).value?.setup ?? false
if (!setupOk) {
console.log("⚠️ Server setup is not complete, running setup proccess.")
let setupScript = await import("./setup")
setupScript = setupScript.default ?? setupScript
try {
for await (let script of setupScript) {
await script()
}
console.log("✅ Server setup complete.")
await Config.updateOne({ key: "server" }, { value: { setup: true } })
return resolve()
} catch (error) {
console.log("❌ Server setup failed.")
console.error(error)
process.exit(1)
}
}
return resolve()
})
}
handleWsAuth = async (socket, token, err) => {
try {
const validation = await Token.validate(token)
if (!validation.valid) {
if (validation.error) {
return err(`auth:server_error`)
}
return err(`auth:token_invalid`)
}
const userData = await User.findById(validation.data.user_id).catch((err) => {
console.error(`[${socket.id}] failed to get user data caused by server error`, err)
return null
})
if (!userData) {
return err(`auth:user_failed`)
}
socket.userData = userData
socket.token = token
socket.session = validation.data
return {
token: token,
username: userData.username,
user_id: userData._id,
}
} catch (error) {
return err(`auth:authentification_failed`, error)
}
}
} }
Boot(API) Boot(API)

View File

@ -0,0 +1,7 @@
import LimitsClass from "@shared-classes/Limits"
export default async (req) => {
const key = req.query.key
return await LimitsClass.get(key)
}

View File

@ -0,0 +1,3 @@
export default () => {
return "pong"
}

View File

@ -0,0 +1,47 @@
import { Config } from "@db_models"
export default async () => {
let serverConfig = await Config.findOne({ key: "server" }).catch(() => {
return false
})
if (!serverConfig) {
console.log("Server config DB is not created, creating it...")
serverConfig = new Config({
key: "server",
value: {
setup: false,
},
})
await serverConfig.save()
}
const setupScriptsCompleted = (serverConfig.value?.setup) ?? false
if (!setupScriptsCompleted) {
console.log("⚠️ Server setup is not complete, running setup proccess.")
let setupScript = await import("./setup")
setupScript = setupScript.default ?? setupScript
try {
for await (let script of setupScript) {
await script()
}
console.log("✅ Server setup complete.")
await Config.updateOne({ key: "server" }, { value: { setup: true } })
serverConfig = await Config.findOne({ key: "server" })
return resolve()
} catch (error) {
console.log("❌ Server setup failed.")
console.error(error)
process.exit(1)
}
}
}

View File

@ -0,0 +1,32 @@
import { Server } from "linebridge/src/server"
import DbManager from "@shared-classes/DbManager"
import RedisClient from "@shared-classes/RedisClient"
import SharedMiddlewares from "@shared-middlewares"
class API extends Server {
static refName = "notifications"
static useEngine = "hyper-express"
static wsRoutesPath = `${__dirname}/ws_routes`
static routesPath = `${__dirname}/routes`
static listen_port = process.env.HTTP_LISTEN_PORT ?? 3009
middlewares = {
...SharedMiddlewares
}
contexts = {
db: new DbManager(),
redis: RedisClient(),
}
async onInitialize() {
await this.contexts.db.initialize()
await this.contexts.redis.initialize()
}
handleWsAuth = require("@shared-lib/handleWsAuth").default
}
Boot(API)

View File

@ -0,0 +1,6 @@
{
"name": "notifications",
"version": "1.0.0",
"main": "index.js",
"license": "MIT"
}

View File

@ -0,0 +1,5 @@
export default () =>{
return {
hi: "hola xd"
}
}

View File

@ -0,0 +1,9 @@
export default async () => {
global.rtengine.io.of("/").emit("new", {
hi: "hola xd"
})
return {
hi: "hola xd"
}
}

View File

@ -1,5 +1,6 @@
export default class Posts { export default class Posts {
static feed = require("./methods/feed").default static timeline = require("./methods/timeline").default
static globalTimeline = require("./methods/globalTimeline").default
static data = require("./methods/data").default static data = require("./methods/data").default
static getLiked = require("./methods/getLiked").default static getLiked = require("./methods/getLiked").default
static getSaved = require("./methods/getSaved").default static getSaved = require("./methods/getSaved").default
@ -10,4 +11,7 @@ export default class Posts {
static toggleLike = require("./methods/toggleLike").default static toggleLike = require("./methods/toggleLike").default
static report = require("./methods/report").default static report = require("./methods/report").default
static flag = require("./methods/flag").default static flag = require("./methods/flag").default
static delete = require("./methods/delete").default
static update = require("./methods/update").default
static replies = require("./methods/replies").default
} }

View File

@ -2,6 +2,7 @@ import requiredFields from "@shared-utils/requiredFields"
import { DateTime } from "luxon" import { DateTime } from "luxon"
import { Post } from "@db_models" import { Post } from "@db_models"
import fullfill from "./fullfill"
export default async (payload = {}) => { export default async (payload = {}) => {
await requiredFields(["user_id"], payload) await requiredFields(["user_id"], payload)
@ -32,9 +33,13 @@ export default async (payload = {}) => {
post = post.toObject() post = post.toObject()
// TODO: create background jobs (nsfw dectection) const result = await fullfill({
posts: post,
for_user_id: user_id
})
// TODO: Push event to Websocket // TODO: create background jobs (nsfw dectection)
global.rtengine.io.of("/").emit(`post.new`, result[0])
return post return post
} }

View File

@ -1,16 +1,23 @@
import { Post } from "@db_models" import { Post } from "@db_models"
import fullfillPostsData from "./fullfill" import fullfillPostsData from "./fullfill"
const maxLimit = 300
export default async (payload = {}) => { export default async (payload = {}) => {
let { let {
for_user_id, for_user_id,
post_id, post_id,
query = {}, query = {},
skip = 0, trim = 0,
limit = 20, limit = 20,
sort = { created_at: -1 }, sort = { created_at: -1 },
} = payload } = payload
// set a hard limit on the number of posts to retrieve, used for pagination
if (limit > maxLimit) {
limit = maxLimit
}
let posts = [] let posts = []
if (post_id) { if (post_id) {
@ -24,7 +31,7 @@ export default async (payload = {}) => {
} else { } else {
posts = await Post.find({ ...query }) posts = await Post.find({ ...query })
.sort(sort) .sort(sort)
.skip(skip) .skip(trim)
.limit(limit) .limit(limit)
} }
@ -32,7 +39,6 @@ export default async (payload = {}) => {
posts = await fullfillPostsData({ posts = await fullfillPostsData({
posts, posts,
for_user_id, for_user_id,
skip,
}) })
// if post_id is specified, return only one post // if post_id is specified, return only one post

View File

@ -0,0 +1,38 @@
import { Post, PostLike, PostSave } from "@db_models"
export default async (payload = {}) => {
let {
post_id
} = payload
if (!post_id) {
throw new OperationError(400, "Missing post_id")
}
await Post.deleteOne({
_id: post_id,
}).catch((err) => {
throw new OperationError(500, `An error has occurred: ${err.message}`)
})
// search for likes
await PostLike.deleteMany({
post_id: post_id,
}).catch((err) => {
throw new OperationError(500, `An error has occurred: ${err.message}`)
})
// deleted from saved
await PostSave.deleteMany({
post_id: post_id,
}).catch((err) => {
throw new OperationError(500, `An error has occurred: ${err.message}`)
})
global.rtengine.io.of("/").emit(`post.delete`, post_id)
global.rtengine.io.of("/").emit(`post.delete.${post_id}`, post_id)
return {
deleted: true,
}
}

View File

@ -4,7 +4,7 @@ export default async (payload = {}) => {
const { const {
for_user_id, for_user_id,
user_id, user_id,
skip, trim,
limit, limit,
} = payload } = payload
@ -14,8 +14,8 @@ export default async (payload = {}) => {
return await GetData({ return await GetData({
for_user_id: for_user_id, for_user_id: for_user_id,
skip, trim: trim,
limit, limit: limit,
query: { query: {
user_id: { user_id: {
$in: user_id $in: user_id

View File

@ -1,4 +1,4 @@
import { User, Comment, PostLike, SavedPost } from "@db_models" import { User, PostLike, PostSave, Post } from "@db_models"
export default async (payload = {}) => { export default async (payload = {}) => {
let { let {
@ -14,33 +14,26 @@ export default async (payload = {}) => {
return [] return []
} }
let savedPostsIds = [] let postsSavesIds = []
if (for_user_id) { if (for_user_id) {
const savedPosts = await SavedPost.find({ user_id: for_user_id }) const postsSaves = await PostSave.find({ user_id: for_user_id })
.sort({ saved_at: -1 }) .sort({ saved_at: -1 })
savedPostsIds = savedPosts.map((savedPost) => savedPost.post_id) postsSavesIds = postsSaves.map((postSave) => postSave.post_id)
} }
let [usersData, likesData, commentsData] = await Promise.all([ let [usersData, likesData, repliesData] = await Promise.all([
User.find({ User.find({
_id: { _id: {
$in: posts.map((post) => post.user_id) $in: posts.map((post) => post.user_id)
} }
}) }).catch(() => { }),
.select("-email")
.select("-birthday"),
PostLike.find({ PostLike.find({
post_id: { post_id: {
$in: posts.map((post) => post._id) $in: posts.map((post) => post._id)
} }
}).catch(() => []), }).catch(() => []),
Comment.find({
parent_id: {
$in: posts.map((post) => post._id)
}
}).catch(() => []),
]) ])
// wrap likesData by post_id // wrap likesData by post_id
@ -54,19 +47,10 @@ export default async (payload = {}) => {
return acc return acc
}, {}) }, {})
// wrap commentsData by post_id
commentsData = commentsData.reduce((acc, comment) => {
if (!acc[comment.parent_id]) {
acc[comment.parent_id] = []
}
acc[comment.parent_id].push(comment)
return acc
}, {})
posts = await Promise.all(posts.map(async (post, index) => { posts = await Promise.all(posts.map(async (post, index) => {
post = post.toObject() if (typeof post.toObject === "function") {
post = post.toObject()
}
let user = usersData.find((user) => user._id.toString() === post.user_id.toString()) let user = usersData.find((user) => user._id.toString() === post.user_id.toString())
@ -77,22 +61,21 @@ export default async (payload = {}) => {
} }
} }
if (post.reply_to) {
post.reply_to_data = await Post.findById(post.reply_to)
}
let likes = likesData[post._id.toString()] ?? [] let likes = likesData[post._id.toString()] ?? []
post.countLikes = likes.length post.countLikes = likes.length
let comments = commentsData[post._id.toString()] ?? []
post.countComments = comments.length
if (for_user_id) { if (for_user_id) {
post.isLiked = likes.some((like) => like.user_id.toString() === for_user_id) post.isLiked = likes.some((like) => like.user_id.toString() === for_user_id)
post.isSaved = savedPostsIds.includes(post._id.toString()) post.isSaved = postsSavesIds.includes(post._id.toString())
} }
return { return {
...post, ...post,
comments: comments.map((comment) => comment._id.toString()),
user, user,
} }
})) }))

Some files were not shown because too many files have changed in this diff Show More