Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/juxtaposition-ui/nodemon.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"watch": [
"src/"
],
"ext": "js,ts,ejs,json,css",
"ext": "js,ts,ejs,tsx,jsx,json,css",
"env": {
"NODE_ENV": "development"
}
Expand Down
6 changes: 6 additions & 0 deletions apps/juxtaposition-ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@neato/config": "^3.0.0",
"@pretendonetwork/grpc": "^2.0.1",
"@repo/grpc-client": "^0.0.0",
"classnames": "^2.5.1",
"colors": "^1.4.0",
"connect-redis": "^8.0.1",
"cookie-parser": "^1.4.7",
Expand Down Expand Up @@ -46,14 +47,19 @@
"pjax": "^0.2.8",
"pngjs": "^7.0.0",
"prom-client": "^15.1.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"redis": "^4.7.0",
"sharp": "^0.33.5",
"tga": "^1.0.7",
"tsx": "^4.19.3"
},
"devDependencies": {
"@pretendonetwork/eslint-config": "^0.0.8",
"@types/hashmap": "^2.3.4",
"@types/node": "^22.13.8",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"browserslist": "^4.24.5",
"browserslist-to-esbuild": "^2.1.1",
"ejs-lint": "^2.0.1",
Expand Down
37 changes: 37 additions & 0 deletions apps/juxtaposition-ui/src/middleware/jsx.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { renderToStaticMarkup } from 'react-dom/server';
import type { RequestHandler } from 'express';

const htmlDoctype = '<!DOCTYPE html>';

/**
* Render JSX as static markup. Only static! No state or event handlers are supported.
*/
export const jsxRenderer: RequestHandler = (request, response, next) => {
response.jsx = (el, addDoctype): typeof response => {
const prefix = addDoctype ? htmlDoctype + '\n' : '';
response.send(prefix + renderToStaticMarkup(el));
return response;
};

response.jsxForDirectory = (opt): typeof response => {
const disabledFor = opt.disableDoctypeFor ?? [];
const directory = request.directory;
if (directory === 'ctr' && opt.ctr) {
response.jsx(opt.ctr, !disabledFor.includes('ctr'));
return response;
}

if (directory === 'portal' && opt.portal) {
response.jsx(opt.portal, !disabledFor.includes('portal'));
return response;
}

if (directory === 'web' && opt.web) {
response.jsx(opt.web, !disabledFor.includes('web'));
return response;
}

throw new Error('Invalid directory to render JSX for');
};
next();
};
2 changes: 2 additions & 0 deletions apps/juxtaposition-ui/src/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const { redisClient } = require('@/redisCache');
const juxt_web = require('@/services/juxt-web');
const { healthzRouter } = require('@/services/healthz');
const { config } = require('@/config');
const { jsxRenderer } = require('@/middleware/jsx');

process.title = 'Pretendo - Juxt-Web';
process.on('SIGTERM', () => {
Expand Down Expand Up @@ -49,6 +50,7 @@ app.get('/ip', (request, response) => response.send(request.ip));

// Create router
logger.info('Setting up Middleware');
app.use(jsxRenderer);
app.use(loggerHttp);
app.use(express.json());
app.use(healthzRouter);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import { buildContext } from '@/services/juxt-web/views/context';
import { CtrMessagesView } from '@/services/juxt-web/views/ctr/messages';
import { PortalMessagesView } from '@/services/juxt-web/views/portal/messages';
import { WebMessagesView } from '@/services/juxt-web/views/web/messages';

const crypto = require('crypto');
const express = require('express');
const moment = require('moment');
Expand All @@ -11,11 +16,11 @@ const router = express.Router();

router.get('/', async function (req, res) {
const conversations = await database.getConversations(req.pid);
const usersMap = await util.getUserHash();
res.render(req.directory + '/messages.ejs', {
moment: moment,
conversations: conversations,
usersMap: usersMap
res.jsxForDirectory({
web: <WebMessagesView conversations={conversations} ctx={buildContext(res)} />,
portal: <PortalMessagesView conversations={conversations} ctx={buildContext(res)} />,
ctr: <CtrMessagesView conversations={conversations} ctx={buildContext(res)} />,
disableDoctypeFor: ['ctr']
});
});

Expand Down
9 changes: 9 additions & 0 deletions apps/juxtaposition-ui/src/services/juxt-web/views/common.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { ReactNode } from "react";

export function InlineScript(props: { src: string }): ReactNode {
return <script dangerouslySetInnerHTML={{ __html: props.src }} />;
}

export function InlineStyle(props: { src: string }): ReactNode {
return <style dangerouslySetInnerHTML={{ __html: props.src }} />;
}
22 changes: 22 additions & 0 deletions apps/juxtaposition-ui/src/services/juxt-web/views/context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { getUserHash } from '@/util';
import type { Response } from 'express';
import type HashMap from 'hashmap';

export type RenderContext = {
lang: Record<string, any>;
cdnUrl: string;
moderator: boolean;
pid: number;
usersMap: HashMap<number, string>; // map of PID -> screen name
};

export function buildContext(res: Response): RenderContext {
const locals = res.locals;
return {
usersMap: getUserHash(),
lang: locals.lang,
cdnUrl: locals.cdnURL,
moderator: locals.moderator,
pid: locals.pid
};
}
71 changes: 71 additions & 0 deletions apps/juxtaposition-ui/src/services/juxt-web/views/ctr/messages.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { ReactNode } from "react";
import type {
ConversationUserModel,
MessagesViewProps,
} from "@/services/juxt-web/views/web/messages";
import moment from "moment";
import cx from "classnames";

export function CtrMessagesView(props: MessagesViewProps): ReactNode {
return (
<ul
className="list-content-with-icon-column arrow-list"
id="news-list-content"
>
{props.conversations.length === 0 ? (
<p className="no-posts-text">{props.ctx.lang.messages.coming_soon}</p>
) : (
props.conversations.map((convo) => {
let userObj: ConversationUserModel | null = null;
let me: ConversationUserModel | null = null;
if (convo.users[0].pid === props.ctx.pid) {
userObj = convo.users[1];
me = convo.users[0];
} else if (convo.users[1].pid === props.ctx.pid) {
userObj = convo.users[0];
me = convo.users[1];
}
if (!me || !userObj) return null;
if (!userObj.pid || !me.pid) return null; // Prevent rendering with incomplete data

return (
<li>
<a
href={`/users/${userObj.pid}`}
data-pjax="#body"
className={cx("icon-container", {
verified: userObj.official,
})}
>
<img
src={`${props.ctx.cdnUrl}/mii/${userObj.pid}/normal_face.png`}
className="icon"
/>
</a>
<a
href={`/friend_messages/${convo.id}`}
data-pjax="#body"
className="arrow-button"
></a>
<div className="body message">
<p>
<span className="nick-name">
{props.ctx.usersMap.get(userObj.pid)}
</span>
<span className="id-name">
@{props.ctx.usersMap.get(userObj.pid)}
</span>
<span> {convo.message_preview}</span>
<span className="timestamp">
{" "}
{moment(convo.last_updated).fromNow()}
</span>
</p>
</div>
</li>
);
})
)}
</ul>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import cx from "classnames";
import { PortalNavBar } from "@/services/juxt-web/views/portal/navbar";
import {
PortalPageBody,
PortalRoot,
} from "@/services/juxt-web/views/portal/root";
import moment from "moment";
import type { ReactNode } from "react";
import type {
ConversationUserModel,
MessagesViewProps,
} from "@/services/juxt-web/views/web/messages";

export function PortalMessagesView(props: MessagesViewProps): ReactNode {
return (
<PortalRoot title={props.ctx.lang.global.messages}>
<PortalNavBar ctx={props.ctx} selection={3} />
<PortalPageBody>
<header id="header">
<h1 id="page-title">{props.ctx.lang.global.messages}</h1>
</header>
<div className="body-content" id="messages-list">
<ul className="list-content-with-icon-and-text arrow-list">
{props.conversations.length === 0 ? (
<p className="no-posts-text">
{props.ctx.lang.messages.coming_soon}
</p>
) : (
props.conversations.map((convo) => {
let userObj: ConversationUserModel | null = null;
let me: ConversationUserModel | null = null;
if (convo.users[0].pid === props.ctx.pid) {
userObj = convo.users[1];
me = convo.users[0];
} else if (convo.users[1].pid === props.ctx.pid) {
userObj = convo.users[0];
me = convo.users[1];
}
if (!me || !userObj) return null;
if (!userObj.pid || !me.pid) return null; // Prevent rendering with incomplete data
return (
<li>
<a
href={`/users/show?pid=${userObj.pid}`}
data-pjax="#body"
className="icon-container trigger"
>
<img
src={`${props.ctx.cdnUrl}/mii/${userObj.pid}/normal_face.png`}
className={cx("icon", {
verified: userObj.official,
})}
/>
</a>
<a
href={`/friend_messages/${convo.id}`}
data-pjax="#body"
className="arrow-button"
></a>
<div className="body">
<p className="title">
<span className="nick-name">
{props.ctx.usersMap.get(userObj.pid)}
</span>
<span className="id-name">
@{props.ctx.usersMap.get(userObj.pid)}
</span>
</p>
<span className="timestamp">
{moment(convo.last_updated).fromNow()}
</span>
<p className="text">{convo.message_preview}</p>
</div>
</li>
);
})
)}
</ul>
</div>
</PortalPageBody>
</PortalRoot>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import type { RenderContext } from "@/services/juxt-web/views/context";
import type { ReactNode } from "react";

export type NavBarProps = {
ctx: RenderContext;
selection: number;
};

export function PortalNavBar(props: NavBarProps): ReactNode {
const selectedClasses = (id: number) =>
id === props.selection ? "selected" : "";

// TODO replace SVG icons with better methods for inline SVG (raw imports / Icon component)
return (
<menu id="nav-menu">
<li id="nav-menu-me" data-tab="me" className={selectedClasses(0)}>
<a href="/users/me" data-pjax="#body" data-sound="SE_WAVE_MENU">
<span className="mii-icon">
<img
src={`${props.ctx.cdnUrl}/mii/${props.ctx.pid}/normal_face.png`}
alt="User Page"
/>
</span>
<span>{props.ctx.lang.global.user_page}</span>
</a>
</li>
<li id="nav-menu-feed" data-tab="feed" className={selectedClasses(1)}>
<a href="/feed" data-pjax="#body" data-sound="SE_WAVE_MENU">
{props.ctx.lang.global.activity_feed}
</a>
</li>
<li
id="nav-menu-community"
data-tab="titles"
className={selectedClasses(2)}
>
<a href="/titles" data-pjax="#body" data-sound="SE_WAVE_MENU">
{props.ctx.lang.global.communities}
</a>
</li>
<li
id="nav-menu-message"
data-tab="message"
className={selectedClasses(3)}
>
<a href="/friend_messages" data-pjax="#body" data-sound="SE_WAVE_MENU">
{props.ctx.lang.global.messages}
<span id="message-badge" className="badge">
0
</span>
</a>
</li>
<li id="nav-menu-news" data-tab="news" className={selectedClasses(4)}>
<a href="/news/my_news" data-pjax="#body" data-sound="SE_WAVE_MENU">
{props.ctx.lang.global.notifications}
<span id="news-badge" className="badge">
0
</span>
</a>
</li>
<li id="nav-menu-exit" onclick="exit()">
<a role="button" data-sound="SE_WAVE_EXIT">
{props.ctx.lang.global.close}
</a>
</li>
<li id="nav-menu-back" className="none" onclick="back()">
<a role="button" className="accesskey-B" data-sound="SE_WAVE_BACK">
{props.ctx.lang.global.go_back}
</a>
</li>
</menu>
);
}
Loading