"start": "yarn build:dev --watch"
},
"lint-staged": {
- "*.{ts,tsx,js}": ["prettier --write", "eslint --fix"],
- "*.{css, scss}": ["prettier --write"],
- "package.json": ["sortpack"]
+ "*.{ts,tsx,js}": [
+ "prettier --write",
+ "eslint --fix"
+ ],
+ "*.{css, scss}": [
+ "prettier --write"
+ ],
+ "package.json": [
+ "sortpack"
+ ]
},
"dependencies": {
"@babel/plugin-proposal-decorators": "^7.21.0",
"inferno-server": "^8.1.1",
"isomorphic-cookie": "^1.2.4",
"jwt-decode": "^3.1.2",
- "lemmy-js-client": "0.17.2-rc.24",
+ "lemmy-js-client": "0.18.0-rc.1",
"lodash": "^4.17.21",
"markdown-it": "^13.0.1",
"markdown-it-container": "^3.0.0",
import { HttpService, RequestState } from "../../services/HttpService";
import {
QueryParams,
+ RouteDataResponse,
commentsToFlatNodes,
communityRSSUrl,
editComment,
import { PostListings } from "../post/post-listings";
import { CommunityLink } from "./community-link";
+type CommunityData = RouteDataResponse<{
+ communityRes: GetCommunityResponse;
+ postsRes: GetPostsResponse;
+ commentsRes: GetCommentsResponse;
+}>;
+
interface State {
communityRes: RequestState<GetCommunityResponse>;
postsRes: RequestState<GetPostsResponse>;
RouteComponentProps<{ name: string }>,
State
> {
- private isoData = setIsoData(this.context);
+ private isoData = setIsoData<CommunityData>(this.context);
state: State = {
communityRes: { state: "empty" },
postsRes: { state: "empty" },
// Only fetch the data if coming from another route
if (FirstLoadService.isFirstLoad) {
- const [communityRes, postsRes, commentsRes] = this.isoData.routeData;
+ const { communityRes, commentsRes, postsRes } = this.isoData.routeData;
+
this.state = {
...this.state,
+ isIsomorphic: true,
+ commentsRes,
communityRes,
postsRes,
- commentsRes,
- isIsomorphic: true,
};
}
}
saveScrollPosition(this.context);
}
- static fetchInitialData({
+ static async fetchInitialData({
client,
path,
query: { dataType: urlDataType, page: urlPage, sort: urlSort },
auth,
}: InitialFetchRequest<QueryParams<CommunityProps>>): Promise<
- RequestState<any>
- >[] {
+ Promise<CommunityData>
+ > {
const pathSplit = path.split("/");
- const promises: Promise<RequestState<any>>[] = [];
const communityName = pathSplit[2];
const communityForm: GetCommunity = {
name: communityName,
auth,
};
- promises.push(client.getCommunity(communityForm));
const dataType = getDataTypeFromQuery(urlDataType);
const page = getPageFromString(urlPage);
+ let postsResponse: RequestState<GetPostsResponse> = { state: "empty" };
+ let commentsResponse: RequestState<GetCommentsResponse> = {
+ state: "empty",
+ };
+
if (dataType === DataType.Post) {
const getPostsForm: GetPosts = {
community_name: communityName,
saved_only: false,
auth,
};
- promises.push(client.getPosts(getPostsForm));
- promises.push(Promise.resolve({ state: "empty" }));
+
+ postsResponse = await client.getPosts(getPostsForm);
} else {
const getCommentsForm: GetComments = {
community_name: communityName,
saved_only: false,
auth,
};
- promises.push(Promise.resolve({ state: "empty" }));
- promises.push(client.getComments(getCommentsForm));
+
+ commentsResponse = await client.getComments(getCommentsForm);
}
- return promises;
+ return {
+ communityRes: await client.getCommunity(communityForm),
+ commentsRes: commentsResponse,
+ postsRes: postsResponse,
+ };
}
get documentTitle(): string {
community_view={res.community_view}
moderators={res.moderators}
admins={site_res.admins}
- online={res.online}
enableNsfw={enableNsfw(site_res)}
editable
allLanguages={site_res.all_languages}
const blockCommunityRes = await HttpService.client.blockCommunity(form);
if (blockCommunityRes.state == "success") {
updateCommunityBlock(blockCommunityRes.data);
+ this.setState(s => {
+ if (s.communityRes.state == "success") {
+ s.communityRes.data.community_view.blocked =
+ blockCommunityRes.data.blocked;
+ }
+ });
}
}
import { FirstLoadService } from "../../services/FirstLoadService";
import { HttpService, RequestState } from "../../services/HttpService";
import {
+ RouteDataResponse,
capitalizeFirstLetter,
fetchThemeList,
myAuthRequired,
import { SiteForm } from "./site-form";
import { TaglineForm } from "./tagline-form";
+type AdminSettingsData = RouteDataResponse<{
+ bannedRes: BannedPersonsResponse;
+ instancesRes: GetFederatedInstancesResponse;
+}>;
+
interface AdminSettingsState {
siteRes: GetSiteResponse;
banned: PersonView[];
instancesRes: RequestState<GetFederatedInstancesResponse>;
bannedRes: RequestState<BannedPersonsResponse>;
leaveAdminTeamRes: RequestState<GetSiteResponse>;
+ emojiLoading: boolean;
+ loading: boolean;
themeList: string[];
isIsomorphic: boolean;
}
export class AdminSettings extends Component<any, AdminSettingsState> {
- private isoData = setIsoData(this.context);
+ private isoData = setIsoData<AdminSettingsData>(this.context);
state: AdminSettingsState = {
siteRes: this.isoData.site_res,
banned: [],
bannedRes: { state: "empty" },
instancesRes: { state: "empty" },
leaveAdminTeamRes: { state: "empty" },
+ emojiLoading: false,
+ loading: false,
themeList: [],
isIsomorphic: false,
};
// Only fetch the data if coming from another route
if (FirstLoadService.isFirstLoad) {
- const [bannedRes, instancesRes] = this.isoData.routeData;
+ const { bannedRes, instancesRes } = this.isoData.routeData;
+
this.state = {
...this.state,
bannedRes,
}
}
- async fetchData() {
- this.setState({
- bannedRes: { state: "loading" },
- instancesRes: { state: "loading" },
- themeList: [],
- loading: true,
- });
-
- const auth = myAuthRequired();
-
- const [bannedRes, instancesRes, themeList] = await Promise.all([
- HttpService.client.getBannedPersons({ auth }),
- HttpService.client.getFederatedInstances({ auth }),
- fetchThemeList(),
- ]);
-
- this.setState({
- bannedRes,
- instancesRes,
- themeList,
- loading: false,
- });
- }
-
- static fetchInitialData({
+ static async fetchInitialData({
auth,
client,
- }: InitialFetchRequest): Promise<any>[] {
- const promises: Promise<RequestState<any>>[] = [];
-
- if (auth) {
- promises.push(client.getBannedPersons({ auth }));
- promises.push(client.getFederatedInstances({ auth }));
- } else {
- promises.push(
- Promise.resolve({ state: "empty" }),
- Promise.resolve({ state: "empty" })
- );
- }
-
- return promises;
+ }: InitialFetchRequest): Promise<AdminSettingsData> {
+ return {
+ bannedRes: await client.getBannedPersons({
+ auth: auth as string,
+ }),
+ instancesRes: await client.getFederatedInstances({
+ auth: auth as string,
+ }),
+ };
}
async componentDidMount() {
onSaveSite={this.handleEditSite}
siteRes={this.state.siteRes}
themeList={this.state.themeList}
+ loading={this.state.loading}
/>
</div>
<div className="col-12 col-md-6">
this.state.siteRes.site_view.local_site_rate_limit
}
onSaveSite={this.handleEditSite}
+ loading={this.state.loading}
/>
),
},
<TaglineForm
taglines={this.state.siteRes.taglines}
onSaveSite={this.handleEditSite}
+ loading={this.state.loading}
/>
</div>
),
onCreate={this.handleCreateEmoji}
onDelete={this.handleDeleteEmoji}
onEdit={this.handleEditEmoji}
+ loading={this.state.emojiLoading}
/>
</div>
),
);
}
+ async fetchData() {
+ this.setState({
+ bannedRes: { state: "loading" },
+ instancesRes: { state: "loading" },
+ themeList: [],
+ });
+
+ const auth = myAuthRequired();
+
+ const [bannedRes, instancesRes, themeList] = await Promise.all([
+ HttpService.client.getBannedPersons({ auth }),
+ HttpService.client.getFederatedInstances({ auth }),
+ fetchThemeList(),
+ ]);
+
+ this.setState({
+ bannedRes,
+ instancesRes,
+ themeList,
+ });
+ }
+
admins() {
return (
<>
}
async handleEditSite(form: EditSite) {
+ this.setState({ loading: true });
+
const editRes = await HttpService.client.editSite(form);
if (editRes.state === "success") {
toast(i18n.t("site_saved"));
}
+ this.setState({ loading: false });
+
return editRes;
}
}
async handleEditEmoji(form: EditCustomEmoji) {
+ this.setState({ emojiLoading: true });
+
const res = await HttpService.client.editCustomEmoji(form);
if (res.state === "success") {
updateEmojiDataModel(res.data.custom_emoji);
}
+
+ this.setState({ emojiLoading: false });
}
async handleDeleteEmoji(form: DeleteCustomEmoji) {
+ this.setState({ emojiLoading: true });
+
const res = await HttpService.client.deleteCustomEmoji(form);
if (res.state === "success") {
removeFromEmojiDataModel(res.data.id);
}
+
+ this.setState({ emojiLoading: false });
}
async handleCreateEmoji(form: CreateCustomEmoji) {
+ this.setState({ emojiLoading: true });
+
const res = await HttpService.client.createCustomEmoji(form);
if (res.state === "success") {
updateEmojiDataModel(res.data.custom_emoji);
}
+
+ this.setState({ emojiLoading: false });
}
}
QueryParams,
relTags,
restoreScrollPosition,
+ RouteDataResponse,
saveScrollPosition,
setIsoData,
setupTippy,
page: number;
}
+type HomeData = RouteDataResponse<{
+ postsRes: GetPostsResponse;
+ commentsRes: GetCommentsResponse;
+ trendingCommunitiesRes: ListCommunitiesResponse;
+}>;
+
+function getRss(listingType: ListingType) {
+ const { sort } = getHomeQueryParams();
+ const auth = myAuth();
+
+ let rss: string | undefined = undefined;
+
+ switch (listingType) {
+ case "All": {
+ rss = `/feeds/all.xml?sort=${sort}`;
+ break;
+ }
+ case "Local": {
+ rss = `/feeds/local.xml?sort=${sort}`;
+ break;
+ }
+ case "Subscribed": {
+ rss = auth ? `/feeds/front/${auth}.xml?sort=${sort}` : undefined;
+ break;
+ }
+ }
+
+ return (
+ rss && (
+ <>
+ <a href={rss} rel={relTags} title="RSS">
+ <Icon icon="rss" classes="text-muted small" />
+ </a>
+ <link rel="alternate" type="application/atom+xml" href={rss} />
+ </>
+ )
+ );
+}
+
function getDataTypeFromQuery(type?: string): DataType {
return type ? DataType[type] : DataType.Post;
}
);
export class Home extends Component<any, HomeState> {
- private isoData = setIsoData(this.context);
+ private isoData = setIsoData<HomeData>(this.context);
state: HomeState = {
postsRes: { state: "empty" },
commentsRes: { state: "empty" },
// Only fetch the data if coming from another route
if (FirstLoadService.isFirstLoad) {
- const [postsRes, commentsRes, trendingCommunitiesRes] =
+ const { trendingCommunitiesRes, commentsRes, postsRes } =
this.isoData.routeData;
this.state = {
...this.state,
- postsRes,
- commentsRes,
trendingCommunitiesRes,
+ commentsRes,
+ postsRes,
tagline: getRandomFromList(this.state?.siteRes?.taglines ?? [])
?.content,
isIsomorphic: true,
}
async componentDidMount() {
- if (!this.state.isIsomorphic || !this.isoData.routeData.length) {
+ if (
+ !this.state.isIsomorphic ||
+ !Object.values(this.isoData.routeData).some(
+ res => res.state === "success" || res.state === "failed"
+ )
+ ) {
await Promise.all([this.fetchTrendingCommunities(), this.fetchData()]);
}
saveScrollPosition(this.context);
}
- static fetchInitialData({
+ static async fetchInitialData({
client,
auth,
query: { dataType: urlDataType, listingType, page: urlPage, sort: urlSort },
- }: InitialFetchRequest<QueryParams<HomeProps>>): Promise<
- RequestState<any>
- >[] {
+ }: InitialFetchRequest<QueryParams<HomeProps>>): Promise<HomeData> {
const dataType = getDataTypeFromQuery(urlDataType);
// TODO figure out auth default_listingType, default_sort_type
const page = urlPage ? Number(urlPage) : 1;
- const promises: Promise<RequestState<any>>[] = [];
+ let postsRes: RequestState<GetPostsResponse> = { state: "empty" };
+ let commentsRes: RequestState<GetCommentsResponse> = {
+ state: "empty",
+ };
if (dataType === DataType.Post) {
const getPostsForm: GetPosts = {
auth,
};
- promises.push(client.getPosts(getPostsForm));
- promises.push(Promise.resolve({ state: "empty" }));
+ postsRes = await client.getPosts(getPostsForm);
} else {
const getCommentsForm: GetComments = {
page,
saved_only: false,
auth,
};
- promises.push(Promise.resolve({ state: "empty" }));
- promises.push(client.getComments(getCommentsForm));
+
+ commentsRes = await client.getComments(getCommentsForm);
}
const trendingCommunitiesForm: ListCommunities = {
limit: trendingFetchLimit,
auth,
};
- promises.push(client.listCommunities(trendingCommunitiesForm));
- return promises;
+ return {
+ trendingCommunitiesRes: await client.listCommunities(
+ trendingCommunitiesForm
+ ),
+ commentsRes,
+ postsRes,
+ };
}
get documentTitle(): string {
></div>
)}
<div className="d-block d-md-none">{this.mobileView}</div>
- {this.posts()}
+ {this.posts}
</main>
<aside className="d-none d-md-block col-md-4">
{this.mySidebar}
siteRes: {
site_view: { counts, site },
admins,
- online,
},
showSubscribedMobile,
showTrendingMobile,
site={site}
admins={admins}
counts={counts}
- online={online}
showLocal={showLocal(this.isoData)}
/>
)}
siteRes: {
site_view: { counts, site },
admins,
- online,
},
} = this.state;
site={site}
admins={admins}
counts={counts}
- online={online}
showLocal={showLocal(this.isoData)}
/>
{this.hasFollows && (
await this.fetchData();
}
- posts() {
+ get posts() {
const { page } = getHomeQueryParams();
return (
const siteRes = this.state.siteRes;
if (dataType === DataType.Post) {
- switch (this.state.postsRes?.state) {
+ switch (this.state.postsRes.state) {
case "loading":
return (
<h5>
<span className="mr-2">
<SortSelect sort={sort} onChange={this.handleSortChange} />
</span>
- {this.getRss(listingType)}
+ {getRss(listingType)}
</div>
);
}
- getRss(listingType: ListingType) {
- const { sort } = getHomeQueryParams();
- const auth = myAuth();
-
- let rss: string | undefined = undefined;
-
- switch (listingType) {
- case "All": {
- rss = `/feeds/all.xml?sort=${sort}`;
- break;
- }
- case "Local": {
- rss = `/feeds/local.xml?sort=${sort}`;
- break;
- }
- case "Subscribed": {
- rss = auth ? `/feeds/front/${auth}.xml?sort=${sort}` : undefined;
- break;
- }
- }
-
- return (
- rss && (
- <>
- <a href={rss} rel={relTags} title="RSS">
- <Icon icon="rss" classes="text-muted small" />
- </a>
- <link rel="alternate" type="application/atom+xml" href={rss} />
- </>
- )
- );
- }
-
async fetchTrendingCommunities() {
this.setState({ trendingCommunitiesRes: { state: "loading" } });
this.setState({
import {
CreatePost as CreatePostI,
GetCommunity,
+ GetCommunityResponse,
GetSiteResponse,
ListCommunitiesResponse,
} from "lemmy-js-client";
import {
Choice,
QueryParams,
+ RouteDataResponse,
enableDownvotes,
enableNsfw,
getIdFromString,
communityId?: number;
}
+type CreatePostData = RouteDataResponse<{
+ communityResponse: GetCommunityResponse;
+ initialCommunitiesRes: ListCommunitiesResponse;
+}>;
+
function getCreatePostQueryParams() {
return getQueryParams<CreatePostProps>({
communityId: getIdFromString,
RouteComponentProps<Record<string, never>>,
CreatePostState
> {
- private isoData = setIsoData(this.context);
+ private isoData = setIsoData<CreatePostData>(this.context);
state: CreatePostState = {
siteRes: this.isoData.site_res,
loading: true,
// Only fetch the data if coming from another route
if (FirstLoadService.isFirstLoad) {
- const [communityRes, listCommunitiesRes] = this.isoData.routeData;
+ const { communityResponse: communityRes, initialCommunitiesRes } =
+ this.isoData.routeData;
+
+ this.state = {
+ ...this.state,
+ loading: false,
+ initialCommunitiesRes,
+ isIsomorphic: true,
+ };
if (communityRes?.state === "success") {
const communityChoice: Choice = {
selectedCommunityChoice: communityChoice,
};
}
-
- this.state = {
- ...this.state,
- loading: false,
- initialCommunitiesRes: listCommunitiesRes,
- isIsomorphic: true,
- };
}
}
if (res.state === "success") {
const postId = res.data.post_view.post.id;
this.props.history.replace(`/post/${postId}`);
+ } else {
+ this.setState({
+ loading: false,
+ });
}
}
- static fetchInitialData({
+ static async fetchInitialData({
client,
query: { communityId },
auth,
- }: InitialFetchRequest<QueryParams<CreatePostProps>>): Promise<
- RequestState<any>
- >[] {
- const promises: Promise<RequestState<any>>[] = [];
+ }: InitialFetchRequest<
+ QueryParams<CreatePostProps>
+ >): Promise<CreatePostData> {
+ const data: CreatePostData = {
+ initialCommunitiesRes: await fetchCommunitiesForOptions(client),
+ communityResponse: { state: "empty" },
+ };
if (communityId) {
const form: GetCommunity = {
id: getIdFromString(communityId),
};
- promises.push(client.getCommunity(form));
- } else {
- promises.push(Promise.resolve({ state: "empty" }));
+ data.communityResponse = await client.getCommunity(form);
}
- promises.push(fetchCommunitiesForOptions(client));
-
- return promises;
+ return data;
}
}
isImage,
myAuth,
restoreScrollPosition,
+ RouteDataResponse,
saveScrollPosition,
setIsoData,
setupTippy,
const commentsShownInterval = 15;
+type PostData = RouteDataResponse<{
+ postRes: GetPostResponse;
+ commentsRes: GetCommentsResponse;
+}>;
+
interface PostState {
postId?: number;
commentId?: number;
}
export class Post extends Component<any, PostState> {
- private isoData = setIsoData(this.context);
+ private isoData = setIsoData<PostData>(this.context);
private commentScrollDebounced: () => void;
state: PostState = {
postRes: { state: "empty" },
// Only fetch the data if coming from another route
if (FirstLoadService.isFirstLoad) {
- const [postRes, commentsRes] = this.isoData.routeData;
+ const { commentsRes, postRes } = this.isoData.routeData;
this.state = {
...this.state,
}
}
- static fetchInitialData({
- auth,
+ static async fetchInitialData({
client,
path,
- }: InitialFetchRequest): Promise<any>[] {
+ auth,
+ }: InitialFetchRequest): Promise<PostData> {
const pathSplit = path.split("/");
- const promises: Promise<RequestState<any>>[] = [];
const pathType = pathSplit.at(1);
const id = pathSplit.at(2) ? Number(pathSplit.at(2)) : undefined;
commentsForm.parent_id = id;
}
- promises.push(client.getPost(postForm));
- promises.push(client.getComments(commentsForm));
-
- return promises;
+ return {
+ postRes: await client.getPost(postForm),
+ commentsRes: await client.getComments(commentsForm),
+ };
}
componentWillUnmount() {
community_view={res.data.community_view}
moderators={res.data.moderators}
admins={this.state.siteRes.admins}
- online={res.data.online}
enableNsfw={enableNsfw(this.state.siteRes)}
showIcon
allLanguages={this.state.siteRes.all_languages}
async handleBlockCommunity(form: BlockCommunity) {
const blockCommunityRes = await HttpService.client.blockCommunity(form);
- // TODO Probably isn't necessary
- this.setState(s => {
- if (
- s.postRes.state == "success" &&
- blockCommunityRes.state == "success"
- ) {
- s.postRes.data.community_view = blockCommunityRes.data.community_view;
- }
- return s;
- });
-
if (blockCommunityRes.state == "success") {
updateCommunityBlock(blockCommunityRes.data);
+ this.setState(s => {
+ if (s.postRes.state == "success") {
+ s.postRes.data.community_view.blocked =
+ blockCommunityRes.data.blocked;
+ }
+ });
}
}
import tippy from "tippy.js";
import Toastify from "toastify-js";
import { getHttpBase } from "./env";
- import { i18n, languages } from "./i18next";
+ import { i18n } from "./i18next";
-import { CommentNodeI, DataType, IsoData, VoteType } from "./interfaces";
+import {
+ CommentNodeI,
+ DataType,
+ IsoData,
+ RouteData,
+ VoteType,
+} from "./interfaces";
import { HttpService, UserService } from "./services";
+import { RequestState } from "./services/HttpService";
let Tribute: any;
if (isBrowser()) {
const imageRegex = /(http)?s?:?(\/\/[^"']*\.(?:jpg|jpeg|gif|png|svg|webp))/;
const videoRegex = /(http)?s?:?(\/\/[^"']*\.(?:mp4|webm))/;
+ const tldRegex = /([a-z0-9]+\.)*[a-z0-9]+\.[a-z]+/;
export function isImage(url: string) {
return imageRegex.test(url);
return !!new URL(str);
}
+ export function validInstanceTLD(str: string) {
+ return tldRegex.test(str);
+ }
+
export function communityRSSUrl(actorId: string, sort: string): string {
const url = new URL(actorId);
return `${url.origin}/feeds${url.pathname}.xml?sort=${sort}`;
} as (...e: T) => R;
}
- export function getLanguages(
- override?: string,
- myUserInfo = UserService.Instance.myUserInfo
- ): string[] {
- const myLang = myUserInfo?.local_user_view.local_user.interface_language;
- const lang = override || myLang || "browser";
-
- if (lang == "browser" && isBrowser()) {
- return getBrowserLanguages();
- } else {
- return [lang];
- }
- }
-
- function getBrowserLanguages(): string[] {
- // Intersect lemmy's langs, with the browser langs
- const langs = languages ? languages.map(l => l.code) : ["en"];
-
- // NOTE, mobile browsers seem to be missing this list, so append en
- const allowedLangs = navigator.languages
- .concat("en")
- .filter(v => langs.includes(v));
- return allowedLangs;
- }
-
export async function fetchThemeList(): Promise<string[]> {
return fetch("/css/themelist").then(res => res.json());
}
return typeof window !== "undefined";
}
-export function setIsoData(context: any): IsoData {
+export function setIsoData<T extends RouteData>(context: any): IsoData<T> {
// If its the browser, you need to deserialize the data from the window
if (isBrowser()) {
return window.isoData;
export function initializeSite(site?: GetSiteResponse) {
UserService.Instance.myUserInfo = site?.my_user;
- i18n.changeLanguage(getLanguages()[0]);
+ i18n.changeLanguage();
if (site) {
setupEmojiDataModel(site.custom_emojis ?? []);
}
}
}
+export type RouteDataResponse<T extends Record<string, any>> = {
+ [K in keyof T]: RequestState<T[K]>;
+};
+
function sleep(millis: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, millis));
}