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
3007 -> ems (External Messaging Service)
3008 -> users
3009 -> unallocated
3009 -> notifications
3010 -> unallocated
3011 -> unallocated
3012 -> unallocated

View File

@ -1,5 +1,4 @@
const path = require("path")
const { builtinModules } = require("module")
const aliases = {
"node:buffer": "buffer",
@ -19,7 +18,7 @@ const aliases = {
hooks: path.join(__dirname, "src/hooks"),
classes: path.join(__dirname, "src/classes"),
"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 = {}) => {
@ -30,11 +29,6 @@ module.exports = (config = {}) => {
config.server = {}
}
// config.define = {
// "global.Uint8Array": "Uint8Array",
// "process.env.NODE_DEBUG": false,
// }
config.resolve.alias = aliases
config.server.port = process.env.listenPort ?? 8000
config.server.host = "0.0.0.0"
@ -56,25 +50,5 @@ module.exports = (config = {}) => {
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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -72,13 +72,16 @@ export default {
},
{
id: "low_performance_mode",
storaged: true,
group: "general",
component: "Switch",
icon: "MdSlowMotionVideo",
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.",
emitEvent: "app.lowPerformanceMode",
props: {
disabled: true
},
storaged: true,
experimental: true,
disabled: true,
},
@ -183,6 +186,23 @@ export default {
storaged: true,
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",
title: "Fetch max items",

View File

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

View File

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

View File

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

View File

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

View File

@ -158,6 +158,9 @@ class ComtyApp extends React.Component {
"clearAllOverlays": function () {
window.app.DrawerController.closeAll()
},
"app.clearInternalStorage": function () {
app.clearInternalStorage()
},
}
static publicMethods = {
@ -248,8 +251,11 @@ class ComtyApp extends React.Component {
/>)
},
openPostCreator: () => {
app.layout.modal.open("post_creator", (props) => <PostCreator {...props} />, {
openPostCreator: (params) => {
app.layout.modal.open("post_creator", (props) => <PostCreator
{...props}
{...params}
/>, {
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()
socket = app.cores.api.instance().wsInstances.chat
socket = app.cores.api.instance().sockets.chat
roomEvents = {
"room:recive:message": (message) => {

View File

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

View File

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

View File

@ -6,16 +6,16 @@ import "./index.less"
export default (props) => {
return <div
className="comments_button"
className="reply_button"
>
<Button
type="ghost"
shape="circle"
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>
}

View File

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

View File

@ -1,13 +1,29 @@
import React from "react"
import { DateTime } from "luxon"
import { Tag } from "antd"
import { Tag, Skeleton } from "antd"
import { Image } from "components"
import { Icons } from "components/Icons"
import PostLink from "components/PostLink"
import PostService from "models/post"
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 goToProfile = () => {
@ -17,7 +33,12 @@ export default (props) => {
const updateTimeAgo = () => {
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)
}
@ -34,22 +55,41 @@ export default (props) => {
}
}, [])
return <div className="post_header" onDoubleClick={props.onDoubleClick}>
<div className="user">
<div className="avatar">
return <div className="post-header" onDoubleClick={props.onDoubleClick}>
{
!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
alt="Avatar"
src={props.postData.user?.avatar}
/>
</div>
<div className="info">
<div className="post-header-user-info">
<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.flags?.includes("nsfw") && <Tag
color="volcano"
@ -59,10 +99,12 @@ export default (props) => {
}
</h1>
<span className="timeago">
<span className="post-header-user-info-timeago">
{timeAgo}
</span>
</div>
</div>
</div>
}
}
export default PostCardHeader

View File

@ -1,9 +1,26 @@
.post_header {
.post-header {
display: flex;
flex-direction: row;
justify-content: space-between;
flex-direction: column;
.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;
flex-direction: row;
align-items: center;
@ -17,7 +34,7 @@
margin-left: 6px;
}
.avatar {
.post-header-user-avatar {
width: 40px;
height: 40px;
@ -30,7 +47,7 @@
}
}
.info {
.post-header-user-info {
display: inline-flex;
flex-direction: column;
@ -51,7 +68,7 @@
color: var(--background-color-contrast);
}
.timeago {
.post-header-user-info-timeago {
font-weight: 400;
font-size: 0.7rem;

View File

@ -1,11 +1,8 @@
import React from "react"
import * as antd from "antd"
import classnames from "classnames"
import Plyr from "plyr-react"
import { CommentsCard } from "components"
import { motion } from "framer-motion"
import { Icons } from "components/Icons"
import { processString } from "utils"
import PostHeader from "./components/header"
@ -13,6 +10,7 @@ import PostActions from "./components/actions"
import PostAttachments from "./components/attachments"
import "./index.less"
import { Divider } from "antd"
const messageRegexs = [
{
@ -43,11 +41,14 @@ const messageRegexs = [
export default class PostCard extends React.PureComponent {
state = {
data: this.props.data,
countLikes: this.props.data.countLikes ?? 0,
countComments: this.props.data.countComments ?? 0,
countReplies: this.props.data.countComments ?? 0,
hasLiked: this.props.data.isLiked ?? false,
hasSaved: this.props.data.isSaved ?? false,
hasReplies: this.props.data.hasReplies ?? false,
open: this.props.defaultOpened ?? false,
@ -55,13 +56,28 @@ export default class PostCard extends React.PureComponent {
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 () => {
if (typeof this.props.events.onClickDelete !== "function") {
console.warn("onClickDelete event is not a function")
return
}
return await this.props.events.onClickDelete(this.props.data)
return await this.props.events.onClickDelete(this.state.data)
}
onClickLike = async () => {
@ -70,7 +86,16 @@ export default class PostCard extends React.PureComponent {
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 () => {
@ -79,7 +104,15 @@ export default class PostCard extends React.PureComponent {
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 () => {
@ -88,57 +121,26 @@ export default class PostCard extends React.PureComponent {
return
}
return await this.props.events.onClickEdit(this.props.data)
return await this.props.events.onClickEdit(this.state.data)
}
onDoubleClick = async () => {
this.handleOpen()
}
onClickComments = async () => {
this.handleOpen()
}
handleOpen = (to) => {
if (typeof to === "undefined") {
to = !this.state.open
onClickReply = async () => {
if (typeof this.props.events.onClickReply !== "function") {
console.warn("onClickReply event is not a function")
return
}
if (typeof this.props.events?.ontoggleOpen === "function") {
this.props.events?.ontoggleOpen(to, this.props.data)
}
this.setState({
open: to,
})
//app.controls.openPostViewer(this.props.data)
return await this.props.events.onClickReply(this.state.data)
}
onLikesUpdate = (data) => {
console.log("onLikesUpdate", data)
if (data.to) {
componentDidUpdate = (prevProps) => {
if (prevProps.data !== this.props.data) {
this.setState({
countLikes: this.state.countLikes + 1,
})
} else {
this.setState({
countLikes: this.state.countLikes - 1,
data: this.props.data,
})
}
}
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) => {
console.error(error)
@ -153,12 +155,28 @@ export default class PostCard extends React.PureComponent {
</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() {
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}
id={this.props.data._id}
id={this.state.data._id}
post_id={this.state.data._id}
style={this.props.style}
user-id={this.props.data.user_id}
user-id={this.state.data.user_id}
context-menu={"postCard-context"}
className={classnames(
"post_card",
@ -168,8 +186,9 @@ export default class PostCard extends React.PureComponent {
)}
>
<PostHeader
postData={this.props.data}
postData={this.state.data}
onDoubleClick={this.onDoubleClick}
disableReplyTag={this.props.disableReplyTag}
/>
<div
@ -180,37 +199,43 @@ export default class PostCard extends React.PureComponent {
>
<div className="message">
{
processString(messageRegexs)(this.props.data.message ?? "")
processString(messageRegexs)(this.state.data.message ?? "")
}
</div>
{
!this.props.disableAttachments && this.props.data.attachments && this.props.data.attachments.length > 0 && <PostAttachments
attachments={this.props.data.attachments}
flags={this.props.data.flags}
!this.props.disableAttachments && this.state.data.attachments && this.state.data.attachments.length > 0 && <PostAttachments
attachments={this.state.data.attachments}
flags={this.state.data.flags}
/>
}
</div>
<PostActions
user_id={this.props.data.user_id}
user_id={this.state.data.user_id}
likesCount={this.state.countLikes}
commentsCount={this.state.countComments}
repliesCount={this.state.countReplies}
defaultLiked={this.state.hasLiked}
defaultSaved={this.state.hasSaved}
actions={{
onClickLike: this.onClickLike,
onClickEdit: this.onClickEdit,
onClickDelete: this.onClickDelete,
onClickSave: this.onClickSave,
onClickComments: this.onClickComments,
onClickReply: this.onClickReply,
}}
/>
<CommentsCard
post_id={this.props.data._id}
visible={this.state.open}
/>
</article>
{
!this.props.disableHasReplies && this.state.hasReplies && <>
<Divider />
<h1>View replies</h1>
</>
}
</motion.div>
}
}

View File

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

View File

@ -1,24 +1,21 @@
import React from "react"
import * as antd from "antd"
import classnames from "classnames"
import { DateTime } from "luxon"
import humanSize from "@tsmx/human-readable"
import PostLink from "components/PostLink"
import { Icons } from "components/Icons"
import clipboardEventFileToFile from "utils/clipboardEventFileToFile"
import clipboardEventFileToFile from "utils/clipboardEventFileToFile"
import PostModel from "models/post"
import "./index.less"
const DEFAULT_POST_POLICY = {
maxMessageLength: 512,
acceptedMimeTypes: ["image/gif", "image/png", "image/jpeg", "image/bmp"],
maximumFileSize: 10 * 1024 * 1024,
acceptedMimeTypes: ["image/gif", "image/png", "image/jpeg", "image/bmp", "video/*"],
maximunFilesPerRequest: 10
}
// TODO: Fix close window when post created
export default class PostCreator extends React.Component {
state = {
@ -92,15 +89,30 @@ export default class PostCreator extends React.Component {
const payload = {
message: postMessage,
attachments: postAttachments,
timestamp: DateTime.local().toISO(),
//timestamp: DateTime.local().toISO(),
}
const response = await PostModel.create(payload).catch(error => {
console.error(error)
antd.message.error(error)
let response = null
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({
loading: false
@ -116,6 +128,10 @@ export default class PostCreator extends React.Component {
if (typeof this.props.close === "function") {
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) {
case "uploading": {
this.toggleUploaderVisibility(false)
@ -424,9 +438,37 @@ export default class PostCreator extends React.Component {
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
this.fetchUploadPolicy()
//this.fetchUploadPolicy()
// add a listener to the window
document.addEventListener("paste", this.handlePaste)
@ -448,6 +490,10 @@ export default class PostCreator extends React.Component {
render() {
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
className={"postCreator"}
ref={this.creatorRef}
@ -455,6 +501,37 @@ export default class PostCreator extends React.Component {
onDragLeave={this.handleDrag}
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="avatar">
<img src={app.userData?.avatar} />
@ -475,7 +552,7 @@ export default class PostCreator extends React.Component {
type="primary"
disabled={loading || !this.canSubmit()}
onClick={this.submit}
icon={loading ? <Icons.LoadingOutlined spin /> : <Icons.Send />}
icon={loading ? <Icons.LoadingOutlined spin /> : (editMode ? <Icons.MdEdit /> : <Icons.Send />)}
/>
</div>
</div>

View File

@ -17,7 +17,29 @@
background-color: var(--background-color-accent);
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 {
display: inline-flex;
@ -58,8 +80,6 @@
width: 100%;
height: 100%;
padding: 10px;
overflow: hidden;
overflow-x: scroll;
@ -125,6 +145,8 @@
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
overflow-x: auto;
@ -146,6 +168,8 @@
height: fit-content;
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 * as antd from "antd"
import { Icons } from "components/Icons"
import { AnimatePresence } from "framer-motion"
import PostCard from "components/PostCard"
import PlaylistTimelineEntry from "components/Music/PlaylistTimelineEntry"
@ -41,21 +42,21 @@ const Entry = React.memo((props) => {
return React.createElement(typeToComponent[data.type ?? "post"] ?? PostCard, {
key: data._id,
data: data,
//disableAttachments: true,
disableReplyTag: props.disableReplyTag,
events: {
onClickLike: props.onLikePost,
onClickSave: props.onSavePost,
onClickDelete: props.onDeletePost,
onClickEdit: props.onEditPost,
onClickReply: props.onReplyPost,
onDoubleClick: props.onDoubleClick,
},
})
})
const PostList = (props) => {
const parentRef = React.useRef()
const PostList = React.forwardRef((props, ref) => {
return <LoadMore
ref={parentRef}
ref={ref}
className="post-list"
loadingComponent={LoadingComponent}
noResultComponent={NoResultComponent}
@ -77,40 +78,20 @@ const PostList = (props) => {
</div>
}
{
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"
>
<AnimatePresence>
{
(data) => <Entry
key={data._id}
data={data}
onLikePost={props.onLikePost}
onSavePost={props.onSavePost}
onDeletePost={props.onDeletePost}
onEditPost={props.onEditPost}
/>
props.list.map((data) => {
return <Entry
key={data._id}
data={data}
{...props}
/>
})
}
</For> */}
</AnimatePresence>
</LoadMore>
}
})
export class PostsListsComponent extends React.Component {
state = {
@ -238,12 +219,15 @@ export class PostsListsComponent extends React.Component {
addPost: this.addPost,
removePost: this.removePost,
addRandomPost: () => {
const randomId = Math.random().toString(36).substring(7)
this.addPost({
_id: Math.random().toString(36).substring(7),
message: `Random post ${Math.random().toString(36).substring(7)}`,
_id: randomId,
message: `Random post ${randomId}`,
user: {
_id: Math.random().toString(36).substring(7),
_id: randomId,
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`)
}
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`)
}
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
}
componentDidUpdate = async (prevProps) => {
componentDidUpdate = async (prevProps, prevState) => {
if (prevProps.list !== this.props.list) {
this.setState({
list: this.props.list,
@ -398,6 +382,22 @@ export class PostsListsComponent extends React.Component {
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) => {
antd.Modal.confirm({
title: "Are you sure you want to delete this post?",
@ -444,13 +444,16 @@ export class PostsListsComponent extends React.Component {
}
const PostListProps = {
listRef: this.listRef,
list: this.state.list,
disableReplyTag: this.props.disableReplyTag,
onLikePost: this.onLikePost,
onSavePost: this.onSavePost,
onDeletePost: this.onDeletePost,
onEditPost: this.onEditPost,
onReplyPost: this.onReplyPost,
onDoubleClick: this.onDoubleClickPost,
onLoadMore: this.onLoadMore,
hasMore: this.state.hasMore,
@ -463,12 +466,14 @@ export class PostsListsComponent extends React.Component {
if (app.isMobile) {
return <PostList
ref={this.listRef}
{...PostListProps}
/>
}
return <div className="post-list_wrapper">
<PostList
ref={this.listRef}
{...PostListProps}
/>
</div>

View File

@ -59,7 +59,7 @@ html {
position: relative;
// WARN: Only use if is a performance issue (If is using virtualized list)
will-change: transform;
//will-change: transform;
overflow: hidden;
overflow-y: overlay;
@ -77,10 +77,14 @@ html {
//margin: auto;
z-index: 150;
background-color: var(--background-color-accent);
.post_card {
border-radius: 0;
border-bottom: 2px solid var(--border-color);
background-color: transparent;
&:first-child {
border-radius: 8px;
@ -95,33 +99,11 @@ html {
border-bottom-left-radius: 8px;
border-bottom-right-radius: 8px;
}
}
.playlistTimelineEntry {
border-radius: 0;
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;
&:last-of-type {
border-bottom: none;
}
}
.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 {

View File

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

View File

@ -1,10 +1,15 @@
import React from "react"
import { Button, Upload } from "antd"
import { Upload, Progress } from "antd"
import classnames from "classnames"
import { Icons } from "components/Icons"
import useHacks from "hooks/useHacks"
import "./index.less"
export default (props) => {
const [uploading, setUploading] = React.useState(false)
const [progess, setProgess] = React.useState(null)
const handleOnStart = (file_uid, file) => {
if (typeof props.onStart === "function") {
@ -32,35 +37,37 @@ export default (props) => {
const handleUpload = async (req) => {
setUploading(true)
setProgess(1)
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) => {
return handleOnProgress(file.uid, progress)
}
}).catch((err) => {
app.notification.new({
title: "Could not upload file",
description: err
}, {
type: "error"
})
setProgess(progress)
handleOnProgress(file.uid, progress)
},
onError: (file, error) => {
setProgess(null)
handleOnError(file.uid, error)
setUploading(false)
},
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
@ -69,25 +76,43 @@ export default (props) => {
props.multiple ?? false
}
accept={
props.accept ?? "image/*"
props.accept ?? [
"image/*",
"video/*",
"audio/*",
]
}
progress={false}
fileList={[]}
>
<Button
icon={props.icon ?? <Icons.Upload
style={{
margin: 0
}}
/>}
loading={uploading}
type={
props.type ?? "round"
className={classnames(
"uploadButton",
{
["uploading"]: !!progess || uploading
}
>
)}
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"
}
</Button>
</div>
</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_text">
<h1>
{user.fullName || user.username}
{user.public_name || user.username}
{user.verified && <Icons.verifiedBadge />}
</h1>
<span>

View File

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

View File

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

View File

@ -2,8 +2,8 @@ import Core from "evite/src/core"
import createClient from "comty.js"
import measurePing from "comty.js/handlers/measurePing"
import request from "comty.js/handlers/request"
import request from "comty.js/request"
import measurePing from "comty.js/helpers/measurePing"
import useRequest from "comty.js/hooks/useRequest"
import { reconnectWebsockets, disconnectWebsockets } from "comty.js"
@ -13,11 +13,11 @@ export default class APICore extends Core {
static bgColor = "coral"
static textColor = "black"
instance = null
client = null
public = {
instance: function () {
return this.instance
client: function () {
return this.client
}.bind(this),
customRequest: request,
listenEvent: this.listenEvent.bind(this),
@ -28,82 +28,45 @@ export default class APICore extends Core {
disconnectWebsockets: disconnectWebsockets,
}
listenEvent(key, handler, instance) {
if (!this.instance.wsInstances[instance ?? "default"]) {
listenEvent(key, handler, instance = "default") {
if (!this.client.sockets[instance]) {
console.error(`[API] Websocket instance ${instance} not found`)
return false
}
return this.instance.wsInstances[instance ?? "default"].on(key, handler)
return this.client.sockets[instance].on(key, handler)
}
unlistenEvent(key, handler, instance) {
if (!this.instance.wsInstances[instance ?? "default"]) {
unlistenEvent(key, handler, instance = "default") {
if (!this.client.sockets[instance]) {
console.error(`[API] Websocket instance ${instance} not found`)
return false
}
return this.instance.wsInstances[instance ?? "default"].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)
// })
// })
return this.client.sockets[instance].off(key, handler)
}
async onInitialize() {
this.instance = await createClient({
this.client = await createClient({
enableWs: true,
})
this.instance.eventBus.on("auth:login_success", () => {
this.client.eventBus.on("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")
})
this.instance.eventBus.on("session.invalid", (error) => {
this.client.eventBus.on("session.invalid", (error) => {
app.eventBus.emit("session.invalid", error)
})
// make a basic request to check if the API is available
await this.instance.instances["default"]({
await this.client.baseRequest({
method: "head",
url: "/",
}).catch((error) => {
@ -115,10 +78,6 @@ export default class APICore extends Core {
`)
})
this.console.debug("[API] Attached to", this.instance)
//this.createPingIntervals()
return this.instance
return this.client
}
}

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 { notification as Notf, Space, Button } from "antd"
import { Icons, createIconRender } from "components/Icons"
import { Translation } from "react-i18next"
import { Haptics } from "@capacitor/haptics"
const NotfTypeToAudio = {
info: "notification",
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(
class NotificationUI {
static async notify(
notification,
options = {
type: "info"
@ -142,27 +106,6 @@ export default class NotificationCore extends Core {
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 EventBus from "evite/src/internals/eventBus"
import SessionModel from "models/session"
class 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()
}
}
}
import ChunkedUpload from "./chunkedUpload"
export default class RemoteStorage extends Core {
static namespace = "remoteStorage"
@ -190,19 +25,15 @@ export default class RemoteStorage extends Core {
onProgress = () => { },
onFinish = () => { },
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) => {
const fn = async () => new Promise((resolve, reject) => {
const uploader = new ChunkedUpload({
endpoint: `${apiEndpoint}/upload/chunk`,
chunkSize: chunkSize,
endpoint: `${app.cores.api.client().mainOrigin}/upload/chunk`,
// TODO: get chunk size from settings
splitChunkSize: 5 * 1024 * 1024, // 5MB in bytes
file: file,
service: service,
})
@ -210,6 +41,13 @@ export default class RemoteStorage extends Core {
uploader.on("error", ({ message }) => {
this.console.error("[Uploader] Error", message)
app.notification.new({
title: "Could not upload file",
description: message
}, {
type: "error"
})
if (typeof onError === "function") {
onError(file, message)
}
@ -219,8 +57,6 @@ export default class RemoteStorage extends Core {
})
uploader.on("progress", ({ percentProgress }) => {
//this.console.debug(`[Uploader] Progress: ${percentProgress}%`)
if (typeof onProgress === "function") {
onProgress(file, percentProgress)
}
@ -229,6 +65,12 @@ export default class RemoteStorage extends Core {
uploader.on("finish", (data) => {
this.console.debug("[Uploader] Finish", data)
app.notification.new({
title: "File uploaded",
}, {
type: "success"
})
if (typeof onFinish === "function") {
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() {
this.musicWs = this.ctx.CORES.api.instance.wsInstances.music
this.musicWs = this.ctx.CORES.api.instance.sockets.music
Object.keys(this.hubEvents).forEach((eventName) => {
this.musicWs.on(eventName, this.hubEvents[eventName])
@ -371,6 +371,7 @@ class MusicSyncSubCore {
}
export default class SyncCore extends Core {
static disabled = true
static namespace = "sync"
static dependencies = ["api", "player"]

View File

@ -6,7 +6,7 @@ export default class WidgetsCore extends Core {
static storeKey = "widgets"
static get apiInstance() {
return app.cores.api.instance().instances.marketplace
return app.cores.api.client().baseRequest
}
public = {
@ -21,7 +21,7 @@ export default class WidgetsCore extends Core {
async onInitialize() {
try {
await WidgetsCore.apiInstance()
//await WidgetsCore.apiInstance()
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) => {
if (this.contentRef.current && !this.contentRef.current.contains(e.target)) {
if (this.props.confirmOnOutsideClick) {
return AntdModal.confirm({
title: this.props.confirmOnClickTitle ?? "Are you sure?",
content: this.props.confirmOnClickContent ?? "Are you sure you want to close this window?",
onOk: () => {
this.close()
}
})
}
return this.close()
if (this.props.confirmOnOutsideClick) {
return AntdModal.confirm({
title: this.props.confirmOnClickTitle ?? "Are you sure?",
content: this.props.confirmOnClickContent ?? "Are you sure you want to close this window?",
onOk: () => {
this.close()
}
})
}
return this.close()
}
render() {
@ -82,14 +80,15 @@ class Modal extends React.Component {
["framed"]: this.props.framed,
}
)}
onTouchEnd={this.handleClickOutside}
onMouseDown={this.handleClickOutside}
>
<div
id="mask_trigger"
onTouchEnd={this.handleClickOutside}
onMouseDown={this.handleClickOutside}
/>
<div
className="app_modal_content"
ref={this.contentRef}
onTouchEnd={this.handleClickOutside}
onMouseDown={this.handleClickOutside}
style={this.props.frameContentStyle}
>
{

View File

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

View File

@ -2,9 +2,10 @@ import React from "react"
import * as antd from "antd"
import classnames from "classnames"
import { Translation } from "react-i18next"
import { motion, AnimatePresence } from "framer-motion"
import { Icons } from "components/Icons"
import { Skeleton, FollowButton, UserCard } from "components"
import { FollowButton, UserCard } from "components"
import { SessionModel, UserModel, FollowsModel } from "models"
import DetailsTab from "./tabs/details"
@ -21,36 +22,6 @@ const TabsComponent = {
"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 {
state = {
requestedUser: null,
@ -66,16 +37,8 @@ export default class Account extends React.Component {
isNotExistent: false,
}
profileRef = React.createRef()
contentRef = React.createRef()
coverComponent = React.createRef()
leftPanelRef = React.createRef()
actionsRef = React.createRef()
componentDidMount = async () => {
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 () => {
const result = await FollowsModel.toggleFollow({
@ -165,8 +121,6 @@ export default class Account extends React.Component {
return
}
this.onPostListTopVisibility(true)
key = key.toLowerCase()
if (this.state.tabActiveKey === key) {
@ -195,11 +149,10 @@ export default class Account extends React.Component {
}
return <div
ref={this.profileRef}
className={classnames(
"accountProfile",
{
["noCover"]: !user.cover,
["withCover"]: user.cover,
}
)}
id="profile"
@ -209,7 +162,6 @@ export default class Account extends React.Component {
className={classnames("cover", {
["expanded"]: this.state.coverExpanded
})}
ref={this.coverComponent}
style={{ backgroundImage: `url("${user.cover}")` }}
onClick={() => this.toggleCoverExpanded()}
id="profile-cover"
@ -217,18 +169,12 @@ export default class Account extends React.Component {
}
<div className="panels">
<div
className="leftPanel"
ref={this.leftPanelRef}
>
<div className="leftPanel">
<UserCard
user={user}
/>
<div
className="actions"
ref={this.actionsRef}
>
<div className="actions">
<FollowButton
count={this.state.followersCount}
onClick={this.onClickFollow}
@ -239,17 +185,33 @@ export default class Account extends React.Component {
</div>
<div
className="content"
className="centerPanel"
ref={this.contentRef}
>
<TabRender
renderKey={this.state.tabActiveKey}
state={this.state}
onTopVisibility={this.onPostListTopVisibility}
/>
<AnimatePresence mode="wait">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
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 className="tabMenuWrapper">
<div className="rightPanel">
<antd.Menu
className="tabMenu"
mode={app.isMobile ? "horizontal" : "vertical"}

View File

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

View File

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

View File

@ -19,7 +19,7 @@ export default (props) => {
return <antd.Result
status="warning"
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 Post from "models/post"
import Feed from "models/feed"
import "./index.less"
export default class ExplorePosts extends React.Component {
render() {
return <PostsList
loadFromModel={Post.getExplorePosts}
loadFromModel={Feed.getGlobalTimelineFeed}
watchTimeline={[
"post.new",
"post.delete",
"feed.new",
"feed.delete",
]}
realtime
/>

View File

@ -16,8 +16,9 @@ const emptyListRender = () => {
export class SavedPosts extends React.Component {
render() {
return <PostsList
emptyListRender={emptyListRender}
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 * as antd from "antd"
import Post from "models/post"
import { PostCard, CommentsCard } from "components"
import PostCard from "components/PostCard"
import PostsList from "components/PostsList"
import PostService from "models/post"
import "./index.less"
export default (props) => {
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 () => {
setData(null)
const data = await Post.getPost({ post_id }).catch(() => {
antd.message.error("Failed to get post")
return false
})
if (data) {
setData(data)
}
if (error) {
return <antd.Result
status="warning"
title="Failed to retrieve post"
subTitle={error.message}
/>
}
React.useEffect(() => {
loadData()
}, [])
if (!data) {
if (loading) {
return <antd.Skeleton active />
}
return <div className="postPage">
<div className="postWrapper">
<PostCard data={data} fullmode />
return <div className="post-page">
<div className="post-page-original">
<h1>Post</h1>
<PostCard
data={result}
/>
</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>
}

View File

@ -1,30 +1,8 @@
.postPage {
.post-page {
display: flex;
flex-direction: row;
flex-direction: column;
width: 100%;
height: 100vh;
overflow: hidden;
.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;
}
gap: 20px;
}

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
await this.setState({
value: updateValue

View File

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

View File

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

View File

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

View File

@ -299,23 +299,100 @@
}
}
.ant-notification-notice {
background-color: var(--background-color-primary) !important;
.ant-notification {
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-description {
color: var(--text-color) !important;
.ant-notification-notice {
display: flex;
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 {
svg {
color: var(--ant-error-color) !important;
@ -518,30 +596,6 @@
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-button-item-wrapper {
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: [] },
flags: { type: Array, default: [] },
reply_to: { type: String, default: null },
updated_at: { type: String, default: null },
}
}

View File

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

View File

@ -15,6 +15,6 @@ export default {
badges: { type: Array, default: [] },
links: { type: Array, default: [] },
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 chokidar from "chokidar"
import IPCRouter from "linebridge/src/server/classes/IPCRouter"
import fastify from "fastify"
import { createProxyMiddleware } from "http-proxy-middleware"
import treeKill from "tree-kill"
import { dots as DefaultSpinner } from "spinnies/spinners.json"
import getInternalIp from "./lib/getInternalIp"
import comtyAscii from "./ascii"
import pkg from "./package.json"
import cors from "linebridge/src/server/middlewares/cors"
import { onExit } from "signal-exit"
import Proxy from "./proxy"
const bootloaderBin = path.resolve(__dirname, "boot")
const servicesPath = path.resolve(__dirname, "services")
@ -51,7 +49,7 @@ async function scanServices() {
return finalServices
}
let internal_proxy = null
let internal_proxy = new Proxy()
let allReady = false
let selectedProcessInstance = null
let internalIp = null
@ -72,7 +70,7 @@ Observable.observe(serviceRegistry, (changes) => {
//console.log(`Updated service | ${path} > ${value}`)
//check if all services all ready
if (Object.values(serviceRegistry).every((service) => service.ready)) {
if (Object.values(serviceRegistry).every((service) => service.initialized)) {
handleAllReady()
}
@ -176,6 +174,8 @@ async function handleAllReady() {
console.log(comtyAscii)
console.log(`🎉 All services[${services.length}] ready!\n`)
console.log(`USE: select <service>, reboot, exit`)
await internal_proxy.listen(9000, "0.0.0.0")
}
// SERVICE WATCHER FUNCTIONS
@ -189,6 +189,8 @@ async function handleNewServiceStarting(id) {
}
async function handleServiceStarted(id) {
serviceRegistry[id].initialized = true
if (serviceRegistry[id].ready === false) {
if (spinnies.pick(id)) {
spinnies.succeed(id, { text: `[${id}][${serviceRegistry[id].index}] Ready` })
@ -199,7 +201,7 @@ async function handleServiceStarted(id) {
}
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 (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
}
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) {
if (msg.type === "log") {
@ -243,21 +230,35 @@ async function handleIPCData(service_id, msg) {
if (msg.type === "router:register") {
if (msg.data.path_overrides) {
for await (let pathOverride of msg.data.path_overrides) {
await registerProxy(
`/${pathOverride}`,
`http://${internalIp}:${msg.data.listen.port}/${pathOverride}`,
{
await internal_proxy.register({
serviceId: service_id,
path: `/${pathOverride}`,
target: `http://${internalIp}:${msg.data.listen.port}/${pathOverride}`,
pathRewrite: {
[`^/${pathOverride}`]: "",
}
)
},
})
}
} else {
await registerProxy(
`/${service_id}`,
`http://${msg.data.listen.ip}:${msg.data.listen.port}`
)
await internal_proxy.register({
serviceId: service_id,
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 }) {
@ -276,11 +277,15 @@ function spawnService({ id, service, cwd }) {
silent: true,
cwd: cwd,
env: instanceEnv,
killSignal: "SIGKILL",
})
instance.reload = () => {
ipcRouter.unregister({ id, instance })
// try to unregister from proxy
internal_proxy.unregisterAllFromService(id)
instance.kill()
instance = spawnService({ id, service, cwd })
@ -340,31 +345,6 @@ function createServiceLogTransformer({ id, color = "bgCyan" }) {
async function main() {
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.log(comtyAscii)
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") {
const ignored = [
...await getIgnoredFiles(cwd),
"**/.cache/**",
"**/node_modules/**",
"**/dist/**",
"**/build/**",
@ -438,7 +419,6 @@ async function main() {
}
}
// create repl
repl.start({
prompt: "> ",
useGlobal: true,
@ -474,11 +454,6 @@ async function main() {
}
})
await internal_proxy.listen({
host: "0.0.0.0",
port: 9000
})
onExit((code, signal) => {
console.clear()
console.log(`\n🛑 Preparing to exit...`)
@ -493,7 +468,11 @@ async function main() {
console.log(`Killing ${instance.id} [${instance.instance.pid}]`)
instance.instance.kill()
treeKill(instance.instance.pid)
}
treeKill(process.pid)
})
}

View File

@ -24,14 +24,21 @@
"clui": "^0.3.6",
"dotenv": "^16.4.4",
"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",
"linebridge": "^0.18.1",
"module-alias": "^2.2.3",
"p-map": "^4.0.0",
"p-queue": "^7.3.4",
"radix3": "^1.1.1",
"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": {
"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.values.mfa_enabled) {
if (userConfig.values["auth:mfa"]) {
let codeVerified = false
// 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 DbManager from "@shared-classes/DbManager"
import RedisClient from "@shared-classes/RedisClient"
import StorageClient from "@shared-classes/StorageClient"
import CacheService from "@shared-classes/CacheService"
import SharedMiddlewares from "@shared-middlewares"
import LimitsClass from "@shared-classes/Limits"
class API extends Server {
static refName = "files"
@ -14,13 +16,12 @@ class API extends Server {
static routesPath = `${__dirname}/routes`
static listen_port = process.env.HTTP_LISTEN_PORT ?? 3002
static maxBodyLength = 1000 * 1000 * 1000
middlewares = {
...SharedMiddlewares
}
contexts = {
db: new DbManager(),
cache: new CacheService(),
redis: RedisClient(),
storage: StorageClient(),
@ -28,12 +29,19 @@ class API extends Server {
applicationKeyId: process.env.B2_KEY_ID,
applicationKey: process.env.B2_APP_KEY,
}),
limits: {},
}
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.storage.initialize()
await this.contexts.b2Storage.authorize()
this.contexts.limits = await LimitsClass.get()
}
}

View File

@ -1,104 +1,54 @@
import path from "path"
import fs from "fs"
import FileUpload from "@shared-classes/FileUpload"
import PostProcess from "@services/post-process"
import ChunkFileUpload from "@shared-classes/ChunkFileUpload"
import RemoteUpload from "@services/remoteUpload"
export default {
useContext: ["cache", "storage", "b2Storage"],
useContext: ["cache", "limits"],
middlewares: [
"withAuthentication",
],
fn: async (req, res) => {
const { cache, storage, b2Storage } = this.default.contexts
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 maxFileSize = 10 * 1000 * 1000 * 1000
const tmpPath = path.resolve(userPath)
// 10MB in bytes
const maxChunkSize = 10 * 1000 * 1000
let build = await ChunkFileUpload(req, {
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)
.catch((err) => {
console.log("err", err)
if (typeof build === "function") {
try {
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) {
return false
} else {
if (typeof build === "function") {
try {
build = await build()
fs.promises.rm(tmpPath, { recursive: true, force: true })
if (!req.headers["no-compression"]) {
build = await PostProcess(build)
}
return result
} catch (error) {
fs.promises.rm(tmpPath, { recursive: true, force: true })
// 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 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)
}
throw new OperationError(error.code ?? 500, error.message ?? "Failed to upload file")
}
}
return res.json({
success: true,
})
return {
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,
} = options
const result = await videoTranscode(file.filepath, file.cachePath, {
const result = await videoTranscode(file.filepath, {
videoCodec,
format,
audioBitrate,
videoBitrate: [videoBitrate, true],
extraOptions: [
"-threads 1"
]
})
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",
}
export default (input, cachePath, params = defaultParams) => {
const maxTasks = 5
export default (input, params = defaultParams) => {
return new Promise((resolve, reject) => {
const filename = path.basename(input)
const outputFilename = `${filename.split(".")[0]}_ff.${params.format ?? "webm"}`
const outputFilepath = `${cachePath}/${outputFilename}`
if (!global.ffmpegTasks) {
global.ffmpegTasks = []
}
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}`)
@ -22,8 +31,8 @@ export default (input, cachePath, params = defaultParams) => {
console.debug(`[TRANSCODING] Finished transcode ${input} to ${outputFilepath}`)
return resolve({
filepath: outputFilepath,
filename: outputFilename,
filepath: outputFilepath,
})
}
@ -42,22 +51,33 @@ export default (input, cachePath, params = defaultParams) => {
}
// chain methods
Object.keys(commands).forEach((key) => {
for (let key in commands) {
if (exec === null) {
exec = ffmpeg(commands[key])
} else {
if (typeof exec[key] !== "function") {
console.warn(`[TRANSCODING] Method ${key} is not a function`)
return false
continue
}
if (key === "extraOptions" && Array.isArray(commands[key])) {
for (const option of commands[key]) {
exec = exec.inputOptions(option)
}
if (Array.isArray(commands[key])) {
exec = exec[key](...commands[key])
} else {
exec = exec[key](commands[key])
}
continue
}
})
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
.on("error", onError)

View File

@ -1,28 +1,16 @@
import { Server } from "linebridge/src/server"
import { Config, User } from "@db_models"
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"
export default class API extends Server {
static refName = "main"
static useEngine = "hyper-express"
static routesPath = `${__dirname}/routes`
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 = {
...require("@middlewares").default,
@ -31,102 +19,16 @@ export default class API extends Server {
events = require("./events")
storage = global.storage = StorageClient()
DB = new DbManager()
contexts = {
db: new DbManager(),
}
async onInitialize() {
await this.DB.initialize()
await this.storage.initialize()
await this.initializeConfigDB()
await this.checkSetup()
await this.contexts.db.initialize()
await StartupDB()
}
initializeConfigDB = async () => {
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)
}
}
handleWsAuth = require("@shared-lib/handleWsAuth").default
}
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 {
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 getLiked = require("./methods/getLiked").default
static getSaved = require("./methods/getSaved").default
@ -10,4 +11,7 @@ export default class Posts {
static toggleLike = require("./methods/toggleLike").default
static report = require("./methods/report").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 { Post } from "@db_models"
import fullfill from "./fullfill"
export default async (payload = {}) => {
await requiredFields(["user_id"], payload)
@ -32,9 +33,13 @@ export default async (payload = {}) => {
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
}

View File

@ -1,16 +1,23 @@
import { Post } from "@db_models"
import fullfillPostsData from "./fullfill"
const maxLimit = 300
export default async (payload = {}) => {
let {
for_user_id,
post_id,
query = {},
skip = 0,
trim = 0,
limit = 20,
sort = { created_at: -1 },
} = payload
// set a hard limit on the number of posts to retrieve, used for pagination
if (limit > maxLimit) {
limit = maxLimit
}
let posts = []
if (post_id) {
@ -24,7 +31,7 @@ export default async (payload = {}) => {
} else {
posts = await Post.find({ ...query })
.sort(sort)
.skip(skip)
.skip(trim)
.limit(limit)
}
@ -32,7 +39,6 @@ export default async (payload = {}) => {
posts = await fullfillPostsData({
posts,
for_user_id,
skip,
})
// 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 {
for_user_id,
user_id,
skip,
trim,
limit,
} = payload
@ -14,8 +14,8 @@ export default async (payload = {}) => {
return await GetData({
for_user_id: for_user_id,
skip,
limit,
trim: trim,
limit: limit,
query: {
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 = {}) => {
let {
@ -14,33 +14,26 @@ export default async (payload = {}) => {
return []
}
let savedPostsIds = []
let postsSavesIds = []
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 })
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({
_id: {
$in: posts.map((post) => post.user_id)
}
})
.select("-email")
.select("-birthday"),
}).catch(() => { }),
PostLike.find({
post_id: {
$in: posts.map((post) => post._id)
}
}).catch(() => []),
Comment.find({
parent_id: {
$in: posts.map((post) => post._id)
}
}).catch(() => []),
])
// wrap likesData by post_id
@ -54,19 +47,10 @@ export default async (payload = {}) => {
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) => {
post = post.toObject()
if (typeof post.toObject === "function") {
post = post.toObject()
}
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()] ?? []
post.countLikes = likes.length
let comments = commentsData[post._id.toString()] ?? []
post.countComments = comments.length
if (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 {
...post,
comments: comments.map((comment) => comment._id.toString()),
user,
}
}))

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