Fixing titleOnly, PostSort, and CommentSort. (#2715)

* Fixing titleOnly, PostSort, and CommentSort.

* SortType, Tagline, Emojis (#2718)

* PostSortType

* Hide post sort types in comment view

* Tagline

* CustomEmoji

* Update lemmy-js-client to 0.20.0-alpha.17

* Prompt before leaving unsaved forms

* Add cancel buttons, only create taglines when saving

* Cleanup SortSelect

* Use markdown url for custom emojis

This prevent SSR and CSR from rendering different images after changing
the image of an emoji, already posted emojis will keep showing the old
image. This will also display the same image on different instances that
have overlapping custom emojis.

* Cleanup EmojisForm sorting

* Use existing CommentSortSelect

* Simpler sort type conversion

---------

Co-authored-by: matc-pub <161147791+matc-pub@users.noreply.github.com>
This commit is contained in:
Dessalines 2024-10-01 14:40:02 -04:00 committed by GitHub
parent 6057c96f0c
commit 6b5da8cfb1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 815 additions and 580 deletions

View file

@ -60,7 +60,7 @@
"inferno-router": "^8.2.3", "inferno-router": "^8.2.3",
"inferno-server": "^8.2.3", "inferno-server": "^8.2.3",
"jwt-decode": "^4.0.0", "jwt-decode": "^4.0.0",
"lemmy-js-client": "0.20.0-alpha.7", "lemmy-js-client": "0.20.0-alpha.17",
"lodash.isequal": "^4.5.0", "lodash.isequal": "^4.5.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
"markdown-it-bidi": "^0.2.0", "markdown-it-bidi": "^0.2.0",

View file

@ -114,8 +114,8 @@ importers:
specifier: ^4.0.0 specifier: ^4.0.0
version: 4.0.0 version: 4.0.0
lemmy-js-client: lemmy-js-client:
specifier: 0.20.0-alpha.7 specifier: 0.20.0-alpha.17
version: 0.20.0-alpha.7 version: 0.20.0-alpha.17
lodash.isequal: lodash.isequal:
specifier: ^4.5.0 specifier: ^4.5.0
version: 4.5.0 version: 4.5.0
@ -3071,8 +3071,8 @@ packages:
leac@0.6.0: leac@0.6.0:
resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==} resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==}
lemmy-js-client@0.20.0-alpha.7: lemmy-js-client@0.20.0-alpha.17:
resolution: {integrity: sha512-lhPs8gJFLX0EvlwkkgtTF2F/v22lKjcQ81u7u5CShrO05Dii33fZ5x9SrEJYHD0qw4FIN/jpfKucN9QnndScpA==} resolution: {integrity: sha512-4iZQtZNldhioTecSgi1LMR4E3uK5IcQ+EuWg4aAXmciOIHxPXPAHy7qSLuHqbzEiL1QP5G3MFwQnlVf/sJkFaQ==}
leven@3.1.0: leven@3.1.0:
resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==}
@ -7811,7 +7811,7 @@ snapshots:
leac@0.6.0: {} leac@0.6.0: {}
lemmy-js-client@0.20.0-alpha.7: {} lemmy-js-client@0.20.0-alpha.17: {}
leven@3.1.0: {} leven@3.1.0: {}

View file

@ -461,3 +461,7 @@ br.big {
.totp-link { .totp-link {
width: fit-content; width: fit-content;
} }
em-emoji-picker {
width: 100%;
}

View file

@ -5,6 +5,7 @@ import App from "../shared/components/app/app";
import { lazyHighlightjs } from "../shared/lazy-highlightjs"; import { lazyHighlightjs } from "../shared/lazy-highlightjs";
import { loadUserLanguage } from "../shared/services/I18NextService"; import { loadUserLanguage } from "../shared/services/I18NextService";
import { verifyDynamicImports } from "../shared/dynamic-imports"; import { verifyDynamicImports } from "../shared/dynamic-imports";
import { setupEmojiDataModel } from "../shared/markdown";
import "bootstrap/js/dist/collapse"; import "bootstrap/js/dist/collapse";
import "bootstrap/js/dist/dropdown"; import "bootstrap/js/dist/dropdown";
@ -22,7 +23,7 @@ async function startClient() {
lazyHighlightjs.enableLazyLoading(); lazyHighlightjs.enableLazyLoading();
await loadUserLanguage(); await Promise.all([loadUserLanguage(), setupEmojiDataModel()]);
const wrapper = ( const wrapper = (
<BrowserRouter> <BrowserRouter>

View file

@ -47,13 +47,13 @@ export class CommentSortSelect extends Component<
<option disabled aria-hidden="true"> <option disabled aria-hidden="true">
{I18NextService.i18n.t("sort_type")} {I18NextService.i18n.t("sort_type")}
</option> </option>
<option value={"Hot"}>{I18NextService.i18n.t("hot")}</option>, <option value="Hot">{I18NextService.i18n.t("hot")}</option>
<option value={"Controversial"}> <option value="Controversial">
{I18NextService.i18n.t("controversial")} {I18NextService.i18n.t("controversial")}
</option> </option>
<option value={"Top"}>{I18NextService.i18n.t("top")}</option>, <option value="Top">{I18NextService.i18n.t("top")}</option>
<option value={"New"}>{I18NextService.i18n.t("new")}</option> <option value="New">{I18NextService.i18n.t("new")}</option>
<option value={"Old"}>{I18NextService.i18n.t("old")}</option> <option value="Old">{I18NextService.i18n.t("old")}</option>
</select> </select>
<a <a
className="sort-select-help text-muted" className="sort-select-help text-muted"

View file

@ -5,6 +5,7 @@ interface PaginatorProps {
page: number; page: number;
onChange(val: number): any; onChange(val: number): any;
nextDisabled: boolean; nextDisabled: boolean;
disabled?: boolean;
} }
export class Paginator extends Component<PaginatorProps, any> { export class Paginator extends Component<PaginatorProps, any> {
@ -18,6 +19,7 @@ export class Paginator extends Component<PaginatorProps, any> {
<button <button
className="btn btn-secondary me-2" className="btn btn-secondary me-2"
onClick={linkEvent(this, this.handlePrev)} onClick={linkEvent(this, this.handlePrev)}
disabled={this.props.disabled}
> >
{I18NextService.i18n.t("prev")} {I18NextService.i18n.t("prev")}
</button> </button>
@ -26,6 +28,7 @@ export class Paginator extends Component<PaginatorProps, any> {
<button <button
className="btn btn-secondary" className="btn btn-secondary"
onClick={linkEvent(this, this.handleNext)} onClick={linkEvent(this, this.handleNext)}
disabled={this.props.disabled}
> >
{I18NextService.i18n.t("next")} {I18NextService.i18n.t("next")}
</button> </button>

View file

@ -1,19 +1,19 @@
import { randomStr } from "@utils/helpers"; import { randomStr } from "@utils/helpers";
import { Component, linkEvent } from "inferno"; import { Component, linkEvent } from "inferno";
import { SortType } from "lemmy-js-client"; import { PostSortType } from "lemmy-js-client";
import { relTags, sortingHelpUrl } from "../../config"; import { relTags, sortingHelpUrl } from "../../config";
import { I18NextService } from "../../services"; import { I18NextService } from "../../services";
import { Icon } from "./icon"; import { Icon } from "./icon";
interface SortSelectProps { interface SortSelectProps {
sort: SortType; sort: PostSortType;
onChange(val: SortType): void; onChange(val: PostSortType): void;
hideHot?: boolean; hideHot?: boolean;
hideMostComments?: boolean; hideMostComments?: boolean;
} }
interface SortSelectState { interface SortSelectState {
sort: SortType; sort: PostSortType;
} }
export class SortSelect extends Component<SortSelectProps, SortSelectState> { export class SortSelect extends Component<SortSelectProps, SortSelectState> {
@ -47,55 +47,53 @@ export class SortSelect extends Component<SortSelectProps, SortSelectState> {
{I18NextService.i18n.t("sort_type")} {I18NextService.i18n.t("sort_type")}
</option> </option>
{!this.props.hideHot && [ {!this.props.hideHot && [
<option key={"Hot"} value={"Hot"}> <option key="Hot" value="Hot">
{I18NextService.i18n.t("hot")} {I18NextService.i18n.t("hot")}
</option>, </option>,
<option key={"Active"} value={"Active"}> <option key="Active" value="Active">
{I18NextService.i18n.t("active")} {I18NextService.i18n.t("active")}
</option>, </option>,
<option key={"Scaled"} value={"Scaled"}> <option key="Scaled" value="Scaled">
{I18NextService.i18n.t("scaled")} {I18NextService.i18n.t("scaled")}
</option>, </option>,
]} ]}
<option value={"Controversial"}> <option value="Controversial">
{I18NextService.i18n.t("controversial")} {I18NextService.i18n.t("controversial")}
</option> </option>
<option value={"New"}>{I18NextService.i18n.t("new")}</option> <option value="New">{I18NextService.i18n.t("new")}</option>
<option value={"Old"}>{I18NextService.i18n.t("old")}</option> <option value="Old">{I18NextService.i18n.t("old")}</option>
{!this.props.hideMostComments && [ {!this.props.hideMostComments && [
<option key={"MostComments"} value={"MostComments"}> <option key="MostComments" value="MostComments">
{I18NextService.i18n.t("most_comments")} {I18NextService.i18n.t("most_comments")}
</option>, </option>,
<option key={"NewComments"} value={"NewComments"}> <option key="NewComments" value="NewComments">
{I18NextService.i18n.t("new_comments")} {I18NextService.i18n.t("new_comments")}
</option>, </option>,
]} ]}
<option disabled aria-hidden="true"> <option disabled aria-hidden="true">
</option> </option>
<option value={"TopHour"}>{I18NextService.i18n.t("top_hour")}</option> <option value="TopHour">{I18NextService.i18n.t("top_hour")}</option>
<option value={"TopSixHour"}> <option value="TopSixHour">
{I18NextService.i18n.t("top_six_hours")} {I18NextService.i18n.t("top_six_hours")}
</option> </option>
<option value={"TopTwelveHour"}> <option value="TopTwelveHour">
{I18NextService.i18n.t("top_twelve_hours")} {I18NextService.i18n.t("top_twelve_hours")}
</option> </option>
<option value={"TopDay"}>{I18NextService.i18n.t("top_day")}</option> <option value="TopDay">{I18NextService.i18n.t("top_day")}</option>
<option value={"TopWeek"}>{I18NextService.i18n.t("top_week")}</option> <option value="TopWeek">{I18NextService.i18n.t("top_week")}</option>
<option value={"TopMonth"}> <option value="TopMonth">{I18NextService.i18n.t("top_month")}</option>
{I18NextService.i18n.t("top_month")} <option value="TopThreeMonths">
</option>
<option value={"TopThreeMonths"}>
{I18NextService.i18n.t("top_three_months")} {I18NextService.i18n.t("top_three_months")}
</option> </option>
<option value={"TopSixMonths"}> <option value="TopSixMonths">
{I18NextService.i18n.t("top_six_months")} {I18NextService.i18n.t("top_six_months")}
</option> </option>
<option value={"TopNineMonths"}> <option value="TopNineMonths">
{I18NextService.i18n.t("top_nine_months")} {I18NextService.i18n.t("top_nine_months")}
</option> </option>
<option value={"TopYear"}>{I18NextService.i18n.t("top_year")}</option> <option value="TopYear">{I18NextService.i18n.t("top_year")}</option>
<option value={"TopAll"}>{I18NextService.i18n.t("top_all")}</option> <option value="TopAll">{I18NextService.i18n.t("top_all")}</option>
</select> </select>
<a <a
className="sort-select-icon text-muted" className="sort-select-icon text-muted"

View file

@ -16,7 +16,7 @@ import {
ListCommunities, ListCommunities,
ListCommunitiesResponse, ListCommunitiesResponse,
ListingType, ListingType,
SortType, PostSortType,
} from "lemmy-js-client"; } from "lemmy-js-client";
import { InitialFetchRequest } from "../../interfaces"; import { InitialFetchRequest } from "../../interfaces";
import { FirstLoadService, I18NextService } from "../../services"; import { FirstLoadService, I18NextService } from "../../services";
@ -55,7 +55,7 @@ interface CommunitiesState {
interface CommunitiesProps { interface CommunitiesProps {
listingType: ListingType; listingType: ListingType;
sort: SortType; sort: PostSortType;
page: number; page: number;
} }
@ -63,8 +63,8 @@ function getListingTypeFromQuery(listingType?: string): ListingType {
return listingType ? (listingType as ListingType) : "Local"; return listingType ? (listingType as ListingType) : "Local";
} }
function getSortTypeFromQuery(type?: string): SortType { function getSortTypeFromQuery(type?: string): PostSortType {
return type ? (type as SortType) : "TopMonth"; return type ? (type as PostSortType) : "TopMonth";
} }
export function getCommunitiesQueryParams(source?: string): CommunitiesProps { export function getCommunitiesQueryParams(source?: string): CommunitiesProps {
@ -302,7 +302,7 @@ export class Communities extends Component<
this.updateUrl({ page }); this.updateUrl({ page });
} }
handleSortChange(val: SortType) { handleSortChange(val: PostSortType) {
this.updateUrl({ sort: val, page: 1 }); this.updateUrl({ sort: val, page: 1 });
} }

View file

@ -1,5 +1,6 @@
import { import {
commentsToFlatNodes, commentsToFlatNodes,
commentToPostSortType,
communityRSSUrl, communityRSSUrl,
editComment, editComment,
editPost, editPost,
@ -81,9 +82,10 @@ import {
RemovePost, RemovePost,
SaveComment, SaveComment,
SavePost, SavePost,
SortType, PostSortType,
SuccessResponse, SuccessResponse,
TransferCommunity, TransferCommunity,
CommentSortType,
} from "lemmy-js-client"; } from "lemmy-js-client";
import { fetchLimit, relTags } from "../../config"; import { fetchLimit, relTags } from "../../config";
import { import {
@ -121,6 +123,7 @@ import { IRoutePropsWithFetch } from "../../routes";
import PostHiddenSelect from "../common/post-hidden-select"; import PostHiddenSelect from "../common/post-hidden-select";
import { isBrowser } from "@utils/browser"; import { isBrowser } from "@utils/browser";
import { LoadingEllipses } from "../common/loading-ellipses"; import { LoadingEllipses } from "../common/loading-ellipses";
import { CommentSortSelect } from "../common/comment-sort-select";
type CommunityData = RouteDataResponse<{ type CommunityData = RouteDataResponse<{
communityRes: GetCommunityResponse; communityRes: GetCommunityResponse;
@ -139,12 +142,12 @@ interface State {
interface CommunityProps { interface CommunityProps {
dataType: DataType; dataType: DataType;
sort: SortType; sort: PostSortType;
pageCursor?: PaginationCursor; pageCursor?: PaginationCursor;
showHidden?: StringBoolean; showHidden?: StringBoolean;
} }
type Fallbacks = { sort: SortType }; type Fallbacks = { sort: PostSortType };
export function getCommunityQueryParams( export function getCommunityQueryParams(
source: string | undefined, source: string | undefined,
@ -162,7 +165,8 @@ export function getCommunityQueryParams(
}, },
source, source,
{ {
sort: local_user?.default_sort_type ?? local_site.default_sort_type, sort:
local_user?.default_post_sort_type ?? local_site.default_post_sort_type,
}, },
); );
} }
@ -171,12 +175,11 @@ function getDataTypeFromQuery(type?: string): DataType {
return type ? DataType[type] : DataType.Post; return type ? DataType[type] : DataType.Post;
} }
function getSortTypeFromQuery(type?: string): SortType { function getSortTypeFromQuery(
const mySortType = type: string | undefined,
UserService.Instance.myUserInfo?.local_user_view.local_user fallback: PostSortType,
.default_sort_type; ): PostSortType {
return type ? (type as PostSortType) : fallback;
return type ? (type as SortType) : (mySortType ?? "Active");
} }
type CommunityPathProps = { name: string }; type CommunityPathProps = { name: string };
@ -215,6 +218,7 @@ export class Community extends Component<CommunityRouteProps, State> {
super(props, context); super(props, context);
this.handleSortChange = this.handleSortChange.bind(this); this.handleSortChange = this.handleSortChange.bind(this);
this.handleCommentSortChange = this.handleCommentSortChange.bind(this);
this.handleDataTypeChange = this.handleDataTypeChange.bind(this); this.handleDataTypeChange = this.handleDataTypeChange.bind(this);
this.handlePageNext = this.handlePageNext.bind(this); this.handlePageNext = this.handlePageNext.bind(this);
this.handlePagePrev = this.handlePagePrev.bind(this); this.handlePagePrev = this.handlePagePrev.bind(this);
@ -628,7 +632,14 @@ export class Community extends Component<CommunityRouteProps, State> {
</span> </span>
)} )}
<span className="me-2"> <span className="me-2">
<SortSelect sort={sort} onChange={this.handleSortChange} /> {this.props.dataType === DataType.Post ? (
<SortSelect sort={sort} onChange={this.handleSortChange} />
) : (
<CommentSortSelect
sort={postToCommentSortType(sort)}
onChange={this.handleCommentSortChange}
/>
)}
</span> </span>
{communityRss && ( {communityRss && (
<> <>
@ -654,8 +665,18 @@ export class Community extends Component<CommunityRouteProps, State> {
this.updateUrl({ pageCursor: nextPage }); this.updateUrl({ pageCursor: nextPage });
} }
handleSortChange(sort: SortType) { handleSortChange(sort: PostSortType) {
this.updateUrl({ sort, pageCursor: undefined }); this.updateUrl({
sort: sort,
pageCursor: undefined,
});
}
handleCommentSortChange(sort: CommentSortType) {
this.updateUrl({
sort: commentToPostSortType(sort),
pageCursor: undefined,
});
} }
handleDataTypeChange(dataType: DataType) { handleDataTypeChange(dataType: DataType) {

View file

@ -6,9 +6,6 @@ import classNames from "classnames";
import { Component } from "inferno"; import { Component } from "inferno";
import { import {
BannedPersonsResponse, BannedPersonsResponse,
CreateCustomEmoji,
DeleteCustomEmoji,
EditCustomEmoji,
EditSite, EditSite,
GetFederatedInstancesResponse, GetFederatedInstancesResponse,
GetSiteResponse, GetSiteResponse,
@ -17,7 +14,6 @@ import {
PersonView, PersonView,
} from "lemmy-js-client"; } from "lemmy-js-client";
import { InitialFetchRequest } from "../../interfaces"; import { InitialFetchRequest } from "../../interfaces";
import { removeFromEmojiDataModel, updateEmojiDataModel } from "../../markdown";
import { FirstLoadService, I18NextService } from "../../services"; import { FirstLoadService, I18NextService } from "../../services";
import { import {
EMPTY_REQUEST, EMPTY_REQUEST,
@ -104,9 +100,6 @@ export class AdminSettings extends Component<
super(props, context); super(props, context);
this.handleEditSite = this.handleEditSite.bind(this); this.handleEditSite = this.handleEditSite.bind(this);
this.handleEditEmoji = this.handleEditEmoji.bind(this);
this.handleDeleteEmoji = this.handleDeleteEmoji.bind(this);
this.handleCreateEmoji = this.handleCreateEmoji.bind(this);
this.handleUploadsPageChange = this.handleUploadsPageChange.bind(this); this.handleUploadsPageChange = this.handleUploadsPageChange.bind(this);
this.handleToggleShowLeaveAdminConfirmation = this.handleToggleShowLeaveAdminConfirmation =
this.handleToggleShowLeaveAdminConfirmation.bind(this); this.handleToggleShowLeaveAdminConfirmation.bind(this);
@ -249,11 +242,7 @@ export class AdminSettings extends Component<
id="taglines-tab-pane" id="taglines-tab-pane"
> >
<div className="row"> <div className="row">
<TaglineForm <TaglineForm />
taglines={this.state.siteRes.taglines}
onSaveSite={this.handleEditSite}
loading={this.state.loading}
/>
</div> </div>
</div> </div>
), ),
@ -270,11 +259,7 @@ export class AdminSettings extends Component<
id="emojis-tab-pane" id="emojis-tab-pane"
> >
<div className="row"> <div className="row">
<EmojiForm <EmojiForm />
onCreate={this.handleCreateEmoji}
onDelete={this.handleDeleteEmoji}
onEdit={this.handleEditEmoji}
/>
</div> </div>
</div> </div>
), ),
@ -431,7 +416,6 @@ export class AdminSettings extends Component<
this.setState(s => { this.setState(s => {
s.siteRes.site_view = editRes.data.site_view; s.siteRes.site_view = editRes.data.site_view;
// TODO: Where to get taglines from? // TODO: Where to get taglines from?
s.siteRes.taglines = editRes.data.taglines;
return s; return s;
}); });
toast(I18NextService.i18n.t("site_saved")); toast(I18NextService.i18n.t("site_saved"));
@ -464,27 +448,6 @@ export class AdminSettings extends Component<
} }
} }
async handleEditEmoji(form: EditCustomEmoji) {
const res = await HttpService.client.editCustomEmoji(form);
if (res.state === "success") {
updateEmojiDataModel(res.data.custom_emoji);
}
}
async handleDeleteEmoji(form: DeleteCustomEmoji) {
const res = await HttpService.client.deleteCustomEmoji(form);
if (res.state === "success") {
removeFromEmojiDataModel(form.id);
}
}
async handleCreateEmoji(form: CreateCustomEmoji) {
const res = await HttpService.client.createCustomEmoji(form);
if (res.state === "success") {
updateEmojiDataModel(res.data.custom_emoji);
}
}
async handleUploadsPageChange(val: number) { async handleUploadsPageChange(val: number) {
this.setState({ uploadsPage: val }); this.setState({ uploadsPage: val });
snapToTop(); snapToTop();

View file

@ -1,79 +1,81 @@
import { setIsoData } from "@utils/app";
import { capitalizeFirstLetter } from "@utils/helpers"; import { capitalizeFirstLetter } from "@utils/helpers";
import { Component, linkEvent } from "inferno"; import { Component, linkEvent } from "inferno";
import { import { CustomEmojiView } from "lemmy-js-client";
CreateCustomEmoji, import { emojiMartCategories, EmojiMartCategory } from "../../markdown";
DeleteCustomEmoji,
EditCustomEmoji,
GetSiteResponse,
} from "lemmy-js-client";
import { customEmojisLookup } from "../../markdown";
import { HttpService, I18NextService } from "../../services"; import { HttpService, I18NextService } from "../../services";
import { pictrsDeleteToast, toast } from "../../toast"; import { pictrsDeleteToast, toast } from "../../toast";
import { EmojiMart } from "../common/emoji-mart"; import { EmojiMart } from "../common/emoji-mart";
import { Icon, Spinner } from "../common/icon"; import { Icon, Spinner } from "../common/icon";
import { Paginator } from "../common/paginator"; import { Paginator } from "../common/paginator";
import { tippyMixin } from "../mixins/tippy-mixin"; import { tippyMixin } from "../mixins/tippy-mixin";
import { isBrowser } from "@utils/browser";
import classNames from "classnames";
import { amAdmin } from "@utils/roles";
import { Prompt } from "inferno-router";
interface EmojiFormProps { interface EditableEmoji {
onEdit(form: EditCustomEmoji): void; change?: "update" | "delete" | "create";
onCreate(form: CreateCustomEmoji): void; emoji: CustomEmojiView;
onDelete(form: DeleteCustomEmoji): void; loading?: boolean;
}
function markForUpdate(editable: EditableEmoji) {
if (editable.change !== "create") {
editable.change = "update";
}
} }
interface EmojiFormState { interface EmojiFormState {
siteRes: GetSiteResponse; emojis: EditableEmoji[]; // Emojis for the current page
customEmojis: CustomEmojiViewForm[]; allEmojis: CustomEmojiView[]; // All emojis for emoji lookup across pages
page: number; emojiMartCustom: EmojiMartCategory[];
} emojiMartKey: number;
interface CustomEmojiViewForm {
id: number;
category: string;
shortcode: string;
image_url: string;
alt_text: string;
keywords: string;
changed: boolean;
page: number; page: number;
loading: boolean; loading: boolean;
} }
@tippyMixin @tippyMixin
export class EmojiForm extends Component<EmojiFormProps, EmojiFormState> { export class EmojiForm extends Component<Record<never, never>, EmojiFormState> {
private isoData = setIsoData(this.context);
private itemsPerPage = 15; private itemsPerPage = 15;
private emptyState: EmojiFormState = { private needsRefetch = true;
siteRes: this.isoData.site_res, state: EmojiFormState = {
customEmojis: this.isoData.site_res.custom_emojis.map((x, index) => ({ emojis: [],
id: x.custom_emoji.id, allEmojis: [],
category: x.custom_emoji.category, emojiMartCustom: [],
shortcode: x.custom_emoji.shortcode, emojiMartKey: 1,
image_url: x.custom_emoji.image_url, loading: false,
alt_text: x.custom_emoji.alt_text,
keywords: x.keywords.map(x => x.keyword).join(" "),
changed: false,
page: 1 + Math.floor(index / this.itemsPerPage),
loading: false,
})),
page: 1, page: 1,
}; };
state: EmojiFormState;
private scrollRef: any = {}; private scrollRef: any = {};
constructor(props: any, context: any) { constructor(props: any, context: any) {
super(props, context); super(props, context);
this.state = this.emptyState;
this.handlePageChange = this.handlePageChange.bind(this); this.handlePageChange = this.handlePageChange.bind(this);
this.handleEmojiClick = this.handleEmojiClick.bind(this); this.handleEmojiClick = this.handleEmojiClick.bind(this);
} }
async componentWillMount() {
if (isBrowser()) {
this.handlePageChange(1);
}
}
hasPendingChanges() {
return this.state.emojis.some(x => x.change);
}
render() { render() {
return ( return (
<div className="home-emojis-form col-12"> <div className="home-emojis-form col-12">
<Prompt
message={I18NextService.i18n.t("block_leaving")}
when={this.hasPendingChanges()}
/>
<h1 className="h4 mb-4">{I18NextService.i18n.t("custom_emojis")}</h1> <h1 className="h4 mb-4">{I18NextService.i18n.t("custom_emojis")}</h1>
{customEmojisLookup.size > 0 && ( {this.state.emojiMartCustom.length > 0 && (
<div> <div>
<EmojiMart <EmojiMart
key={this.state.emojiMartKey}
onEmojiClick={this.handleEmojiClick} onEmojiClick={this.handleEmojiClick}
pickerOptions={this.configurePicker()} pickerOptions={this.configurePicker()}
></EmojiMart> ></EmojiMart>
@ -87,6 +89,10 @@ export class EmojiForm extends Component<EmojiFormProps, EmojiFormState> {
<thead className="pointer"> <thead className="pointer">
<tr> <tr>
<th>{I18NextService.i18n.t("column_emoji")}</th> <th>{I18NextService.i18n.t("column_emoji")}</th>
<th
className="text-right"
// Upload button
/>
<th className="text-right"> <th className="text-right">
{I18NextService.i18n.t("column_shortcode")} {I18NextService.i18n.t("column_shortcode")}
</th> </th>
@ -102,20 +108,15 @@ export class EmojiForm extends Component<EmojiFormProps, EmojiFormState> {
<th className="text-right d-lg-table-cell"> <th className="text-right d-lg-table-cell">
{I18NextService.i18n.t("column_keywords")} {I18NextService.i18n.t("column_keywords")}
</th> </th>
<th></th>
<th style="width:121px"></th> <th style="width:121px"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{this.state.customEmojis {this.state.emojis.map((editable: EditableEmoji, index) => {
.slice( const cv = editable.emoji.custom_emoji;
Number((this.state.page - 1) * this.itemsPerPage), return (
Number( <tr key={index}>
(this.state.page - 1) * this.itemsPerPage +
this.itemsPerPage,
),
)
.map((cv, index) => (
<tr key={index} ref={e => (this.scrollRef[cv.shortcode] = e)}>
<td style="text-align:center;"> <td style="text-align:center;">
{cv.image_url.length > 0 && ( {cv.image_url.length > 0 && (
<img <img
@ -124,7 +125,9 @@ export class EmojiForm extends Component<EmojiFormProps, EmojiFormState> {
alt={cv.alt_text} alt={cv.alt_text}
/> />
)} )}
{cv.image_url.length === 0 && ( </td>
<td>
{
<label <label
// TODO: Fix this linting violation // TODO: Fix this linting violation
// eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex // eslint-disable-next-line jsx-a11y/no-noninteractive-tabindex
@ -150,7 +153,7 @@ export class EmojiForm extends Component<EmojiFormProps, EmojiFormState> {
)} )}
/> />
</label> </label>
)} }
</td> </td>
<td className="text-right"> <td className="text-right">
<input <input
@ -167,6 +170,7 @@ export class EmojiForm extends Component<EmojiFormProps, EmojiFormState> {
</td> </td>
<td className="text-right"> <td className="text-right">
<input <input
ref={e => (this.scrollRef[cv.shortcode] = e)}
type="text" type="text"
placeholder="Category" placeholder="Category"
className="form-control" className="form-control"
@ -206,31 +210,54 @@ export class EmojiForm extends Component<EmojiFormProps, EmojiFormState> {
type="text" type="text"
placeholder="Keywords" placeholder="Keywords"
className="form-control" className="form-control"
value={cv.keywords} value={editable.emoji.keywords
.map(k => k.keyword)
.join(" ")}
onInput={linkEvent( onInput={linkEvent(
{ form: this, index: index }, { form: this, index: index },
this.handleEmojiKeywordChange, this.handleEmojiKeywordChange,
)} )}
/> />
</td> </td>
<td
className={classNames("", {
"border-info": editable.change === "update",
"border-danger": editable.change === "delete",
"border-warning": editable.change === "create",
})}
>
{editable.change === "update" && (
<span>
<Icon icon="transfer" />
</span>
)}
{editable.change === "delete" && (
<span>
<Icon icon="trash" />
</span>
)}
{editable.change === "create" && (
<span>
<Icon icon="add" />
</span>
)}
</td>
<td> <td>
<div> <div class="row flex-nowrap g-0">
<span title={this.getEditTooltip(cv)}> <span class="col" title={this.getEditTooltip(editable)}>
<button <button
className={ className={classNames("btn btn-link btn-animate", {
(this.canEdit(cv) "text-success": this.canSave(editable),
? "text-success " })}
: "text-muted ") + "btn btn-link btn-animate"
}
onClick={linkEvent( onClick={linkEvent(
{ i: this, cv: cv }, { i: this, cv: editable },
this.handleEditEmojiClick, this.handleSaveEmojiClick,
)} )}
data-tippy-content={I18NextService.i18n.t("save")} data-tippy-content={I18NextService.i18n.t("save")}
aria-label={I18NextService.i18n.t("save")} aria-label={I18NextService.i18n.t("save")}
disabled={!this.canEdit(cv)} disabled={!this.canSave(editable)}
> >
{cv.loading ? ( {editable.loading ? (
<Spinner /> <Spinner />
) : ( ) : (
capitalizeFirstLetter( capitalizeFirstLetter(
@ -240,14 +267,14 @@ export class EmojiForm extends Component<EmojiFormProps, EmojiFormState> {
</button> </button>
</span> </span>
<button <button
className="btn btn-link btn-animate text-muted" className="col btn btn-link btn-animate text-muted"
onClick={linkEvent( onClick={linkEvent(
{ i: this, index: index, cv: cv }, { i: this, index: index, cv: editable },
this.handleDeleteEmojiClick, this.handleDeleteEmojiClick,
)} )}
data-tippy-content={I18NextService.i18n.t("delete")} data-tippy-content={I18NextService.i18n.t("delete")}
aria-label={I18NextService.i18n.t("delete")} aria-label={I18NextService.i18n.t("delete")}
disabled={cv.loading} disabled={editable.loading}
title={I18NextService.i18n.t("delete")} title={I18NextService.i18n.t("delete")}
> >
<Icon <Icon
@ -255,10 +282,28 @@ export class EmojiForm extends Component<EmojiFormProps, EmojiFormState> {
classes="icon-inline text-danger" classes="icon-inline text-danger"
/> />
</button> </button>
<button
className={classNames(
"col btn btn-link btn-animate",
{
"text-danger": !!editable.change,
},
)}
onClick={linkEvent(
{ i: this, cv: editable },
this.handleCancelEmojiClick,
)}
data-tippy-content={I18NextService.i18n.t("cancel")}
aria-label={I18NextService.i18n.t("cancel")}
disabled={!editable.change}
>
{I18NextService.i18n.t("cancel")}
</button>
</div> </div>
</td> </td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
<br /> <br />
@ -273,43 +318,91 @@ export class EmojiForm extends Component<EmojiFormProps, EmojiFormState> {
page={this.state.page} page={this.state.page}
onChange={this.handlePageChange} onChange={this.handlePageChange}
nextDisabled={false} nextDisabled={false}
disabled={this.hasPendingChanges()}
/> />
</div> </div>
</div> </div>
); );
} }
canEdit(cv: CustomEmojiViewForm) { canSave(cv: EditableEmoji) {
const noEmptyFields = const requiredFields =
cv.alt_text.length > 0 && cv.emoji.custom_emoji.image_url.length > 0 &&
cv.category.length > 0 && cv.emoji.custom_emoji.shortcode.length > 0;
cv.image_url.length > 0 && return requiredFields && !cv.loading;
cv.shortcode.length > 0;
const noDuplicateShortCodes =
this.state.customEmojis.filter(
x => x.shortcode === cv.shortcode && x.id !== cv.id,
).length === 0;
return noEmptyFields && noDuplicateShortCodes && !cv.loading && cv.changed;
} }
getEditTooltip(cv: CustomEmojiViewForm) { getEditTooltip(cv: EditableEmoji) {
if (this.canEdit(cv)) return I18NextService.i18n.t("save"); if (this.canSave(cv)) return I18NextService.i18n.t("save");
else return I18NextService.i18n.t("custom_emoji_save_validation"); else return I18NextService.i18n.t("custom_emoji_save_validation");
} }
handlePageChange(page: number) { async handlePageChange(page: number) {
this.setState({ page: page }); this.setState({ loading: true });
let allEmojis: CustomEmojiView[] = this.state.allEmojis;
let emojiMartCustom: EmojiMartCategory[] = this.state.emojiMartCustom;
let emojiMartKey: number = this.state.emojiMartKey;
if (this.needsRefetch) {
const emojiRes = await HttpService.client.listCustomEmojis({
ignore_page_limits: true,
});
if (emojiRes.state === "success") {
this.needsRefetch = false;
allEmojis = emojiRes.data.custom_emojis;
allEmojis.sort((a, b) => {
const categoryOrder = a.custom_emoji.category.localeCompare(
b.custom_emoji.category,
);
if (categoryOrder === 0) {
return a.custom_emoji.shortcode.localeCompare(
b.custom_emoji.shortcode,
);
}
return categoryOrder;
});
}
emojiMartCustom = emojiMartCategories(allEmojis);
emojiMartKey++;
}
if (allEmojis) {
const startIndex = (page - 1) * this.itemsPerPage;
const emojis = allEmojis
.slice(startIndex, startIndex + this.itemsPerPage)
.map(x => ({ emoji: structuredClone(x) })); // clone for restore after cancel
this.setState({
loading: false,
allEmojis,
emojiMartCustom,
emojiMartKey,
emojis,
page,
});
} else {
this.setState({ loading: false, page });
}
} }
handleEmojiClick(e: any) { async handleEmojiClick(e: any) {
const view = customEmojisLookup.get(e.id); const emojiIndex = this.state.allEmojis.findIndex(
if (view) { x => x.custom_emoji.shortcode === e.id,
const page = this.state.customEmojis.find( );
x => x.id === view.custom_emoji.id, if (emojiIndex >= 0) {
)?.page; const { shortcode } = this.state.allEmojis[emojiIndex].custom_emoji;
if (page) { const page = Math.floor(emojiIndex / this.itemsPerPage) + 1;
this.setState({ page: page }); if (page !== this.state.page) {
this.scrollRef[view.custom_emoji.shortcode].scrollIntoView(); if (
this.hasPendingChanges() &&
!confirm(I18NextService.i18n.t("block_leaving"))
) {
return;
}
await this.handlePageChange(page);
await new Promise(r => setTimeout(r));
}
if (shortcode) {
const categoryInput: HTMLInputElement | undefined =
this.scrollRef[shortcode];
categoryInput?.focus();
} }
} }
} }
@ -318,32 +411,22 @@ export class EmojiForm extends Component<EmojiFormProps, EmojiFormState> {
props: { form: EmojiForm; index: number }, props: { form: EmojiForm; index: number },
event: any, event: any,
) { ) {
const custom_emojis = [...props.form.state.customEmojis]; const editable: EditableEmoji = props.form.state.emojis[props.index];
const pagedIndex = props.form.setState(() => {
(props.form.state.page - 1) * props.form.itemsPerPage + props.index; markForUpdate(editable);
const item = { editable.emoji.custom_emoji.category = event.target.value;
...props.form.state.customEmojis[pagedIndex], });
category: event.target.value,
changed: true,
};
custom_emojis[Number(pagedIndex)] = item;
props.form.setState({ customEmojis: custom_emojis });
} }
handleEmojiShortCodeChange( handleEmojiShortCodeChange(
props: { form: EmojiForm; index: number }, props: { form: EmojiForm; index: number },
event: any, event: any,
) { ) {
const custom_emojis = [...props.form.state.customEmojis]; const editable: EditableEmoji = props.form.state.emojis[props.index];
const pagedIndex = props.form.setState(() => {
(props.form.state.page - 1) * props.form.itemsPerPage + props.index; markForUpdate(editable);
const item = { editable.emoji.custom_emoji.shortcode = event.target.value;
...props.form.state.customEmojis[pagedIndex], });
shortcode: event.target.value,
changed: true,
};
custom_emojis[Number(pagedIndex)] = item;
props.form.setState({ customEmojis: custom_emojis });
} }
handleEmojiImageUrlChange( handleEmojiImageUrlChange(
@ -354,28 +437,11 @@ export class EmojiForm extends Component<EmojiFormProps, EmojiFormState> {
}: { form: EmojiForm; index: number; overrideValue: string | null }, }: { form: EmojiForm; index: number; overrideValue: string | null },
event: any, event: any,
) { ) {
form.setState(prevState => { const editable: EditableEmoji = form.state.emojis[index];
const custom_emojis = [...form.state.customEmojis]; form.setState(() => {
const pagedIndex = (form.state.page - 1) * form.itemsPerPage + index; markForUpdate(editable);
const item = { editable.emoji.custom_emoji.image_url =
...form.state.customEmojis[pagedIndex], overrideValue ?? event.target.value;
image_url: overrideValue ?? event.target.value,
changed: true,
};
custom_emojis[Number(pagedIndex)] = item;
return {
...prevState,
customEmojis: prevState.customEmojis.map((ce, i) =>
i === pagedIndex
? {
...ce,
image_url: overrideValue ?? event.target.value,
changed: true,
loading: false,
}
: ce,
),
};
}); });
} }
@ -383,97 +449,117 @@ export class EmojiForm extends Component<EmojiFormProps, EmojiFormState> {
props: { form: EmojiForm; index: number }, props: { form: EmojiForm; index: number },
event: any, event: any,
) { ) {
const custom_emojis = [...props.form.state.customEmojis]; const editable: EditableEmoji = props.form.state.emojis[props.index];
const pagedIndex = props.form.setState(() => {
(props.form.state.page - 1) * props.form.itemsPerPage + props.index; markForUpdate(editable);
const item = { editable.emoji.custom_emoji.alt_text = event.target.value;
...props.form.state.customEmojis[pagedIndex], });
alt_text: event.target.value,
changed: true,
};
custom_emojis[Number(pagedIndex)] = item;
props.form.setState({ customEmojis: custom_emojis });
} }
handleEmojiKeywordChange( handleEmojiKeywordChange(
props: { form: EmojiForm; index: number }, props: { form: EmojiForm; index: number },
event: any, event: any,
) { ) {
const custom_emojis = [...props.form.state.customEmojis]; const editable: EditableEmoji = props.form.state.emojis[props.index];
const pagedIndex = props.form.setState(() => {
(props.form.state.page - 1) * props.form.itemsPerPage + props.index; markForUpdate(editable);
const item = { editable.emoji.keywords = event.target.value
...props.form.state.customEmojis[pagedIndex], .split(" ")
keywords: event.target.value, .map((x: string) => ({ id: -1, keyword: x }));
changed: true, });
};
custom_emojis[Number(pagedIndex)] = item;
props.form.setState({ customEmojis: custom_emojis });
} }
handleDeleteEmojiClick(d: { handleDeleteEmojiClick(d: {
i: EmojiForm; i: EmojiForm;
index: number; index: number;
cv: CustomEmojiViewForm; cv: EditableEmoji;
}) { }) {
const pagedIndex = (d.i.state.page - 1) * d.i.itemsPerPage + d.index; if (d.cv.change === "create") {
if (d.cv.id !== 0) { // This drops the entry immediately, other deletes have to be saved.
d.i.props.onDelete({ d.i.setState(prev => ({
id: d.cv.id, emojis: prev.emojis.filter(x => x !== d.cv),
}); }));
} else { } else {
const custom_emojis = [...d.i.state.customEmojis]; d.i.setState(() => {
custom_emojis.splice(Number(pagedIndex), 1); d.cv.change = "delete";
d.i.setState({ customEmojis: custom_emojis });
}
}
handleEditEmojiClick(d: { i: EmojiForm; cv: CustomEmojiViewForm }) {
const keywords = d.cv.keywords
.split(" ")
.filter(x => x.length > 0) as string[];
const uniqueKeywords = Array.from(new Set(keywords));
if (d.cv.id !== 0) {
d.i.props.onEdit({
id: d.cv.id,
category: d.cv.category,
image_url: d.cv.image_url,
alt_text: d.cv.alt_text,
keywords: uniqueKeywords,
});
} else {
d.i.props.onCreate({
category: d.cv.category,
shortcode: d.cv.shortcode,
image_url: d.cv.image_url,
alt_text: d.cv.alt_text,
keywords: uniqueKeywords,
}); });
} }
} }
handleAddEmojiClick(form: EmojiForm, event: any) { async handleSaveEmojiClick(d: { i: EmojiForm; cv: EditableEmoji }) {
d.i.needsRefetch = true;
const editable = d.cv;
if (editable.change === "update") {
const resp = await HttpService.client.editCustomEmoji({
...editable.emoji.custom_emoji,
keywords: editable.emoji.keywords.map(x => x.keyword),
});
if (resp.state === "success") {
d.i.setState(() => {
editable.emoji = resp.data.custom_emoji;
editable.change = undefined;
});
}
} else if (editable.change === "delete") {
const resp = await HttpService.client.deleteCustomEmoji(
editable.emoji.custom_emoji,
);
if (resp.state === "success") {
d.i.setState(prev => ({
emojis: prev.emojis.filter(x => x !== editable),
}));
}
} else if (editable.change === "create") {
const resp = await HttpService.client.createCustomEmoji({
...editable.emoji.custom_emoji,
keywords: editable.emoji.keywords.map(x => x.keyword),
});
if (resp.state === "success") {
d.i.setState(() => {
editable.emoji = resp.data.custom_emoji;
editable.change = undefined;
});
}
}
}
async handleCancelEmojiClick(d: { i: EmojiForm; cv: EditableEmoji }) {
if (d.cv.change === "create") {
d.i.setState(() => {
return {
emojis: d.i.state.emojis.filter(x => x !== d.cv),
};
});
} else if (d.cv.change === "update" || d.cv.change === "delete") {
const original = d.i.state.allEmojis.find(
x => x.custom_emoji.id === d.cv.emoji.custom_emoji.id,
);
if (original) {
d.i.setState(() => {
d.cv.emoji = structuredClone(original);
d.cv.change = undefined;
});
}
}
}
async handleAddEmojiClick(form: EmojiForm, event: any) {
event.preventDefault(); event.preventDefault();
form.setState(prevState => { form.setState(prev => {
const page = prev.emojis.push({
1 + Math.floor(prevState.customEmojis.length / form.itemsPerPage); emoji: {
const item: CustomEmojiViewForm = { custom_emoji: {
id: 0, id: -1,
shortcode: "", published: "",
alt_text: "", category: "",
category: "", shortcode: "",
image_url: "", image_url: "",
keywords: "", alt_text: "",
changed: false, },
page: page, keywords: [],
loading: false, },
}; change: "create",
});
return {
...prevState,
customEmojis: [...prevState.customEmojis, item],
page,
};
}); });
} }
@ -489,14 +575,15 @@ export class EmojiForm extends Component<EmojiFormProps, EmojiFormState> {
file = event; file = event;
} }
form.setState(prevState => ({ const editable = form.state.emojis[index];
...prevState, form.setState(() => {
customEmojis: prevState.customEmojis.map((cv, i) => editable.loading = true;
i === index ? { ...cv, loading: true } : cv, });
),
}));
HttpService.client.uploadImage({ image: file }).then(res => { HttpService.client.uploadImage({ image: file }).then(res => {
form.setState(() => {
editable.loading = false;
});
if (res.state === "success") { if (res.state === "success") {
if (res.data.msg === "ok") { if (res.data.msg === "ok") {
pictrsDeleteToast(file.name, res.data.delete_url as string); pictrsDeleteToast(file.name, res.data.delete_url as string);
@ -517,10 +604,20 @@ export class EmojiForm extends Component<EmojiFormProps, EmojiFormState> {
} }
configurePicker(): any { configurePicker(): any {
const custom = this.state.emojiMartCustom;
if (process.env["NODE_ENV"] === "development") {
// Once an emoji-mart Picker is initialized with these options, other
// instances also only show the custom emojis.
console.assert(
amAdmin(),
"EmojiMart doesn't deal well with differently configured instances.",
);
}
return { return {
data: { categories: [], emojis: [], aliases: [] }, data: { categories: [], emojis: [], aliases: [] },
maxFrequentRows: 0, maxFrequentRows: 0,
dynamicWidth: true, dynamicWidth: true,
custom,
}; };
} }
} }

View file

@ -1,5 +1,6 @@
import { import {
commentsToFlatNodes, commentsToFlatNodes,
commentToPostSortType,
editComment, editComment,
editPost, editPost,
editWith, editWith,
@ -16,7 +17,6 @@ import {
import { import {
getQueryParams, getQueryParams,
getQueryString, getQueryString,
getRandomFromList,
resourcesSettled, resourcesSettled,
} from "@utils/helpers"; } from "@utils/helpers";
import { scrollMixin } from "../mixins/scroll-mixin"; import { scrollMixin } from "../mixins/scroll-mixin";
@ -67,9 +67,10 @@ import {
RemovePost, RemovePost,
SaveComment, SaveComment,
SavePost, SavePost,
SortType, PostSortType,
SuccessResponse, SuccessResponse,
TransferCommunity, TransferCommunity,
CommentSortType,
} from "lemmy-js-client"; } from "lemmy-js-client";
import { fetchLimit, relTags } from "../../config"; import { fetchLimit, relTags } from "../../config";
import { import {
@ -107,6 +108,7 @@ import { RouteComponentProps } from "inferno-router/dist/Route";
import { IRoutePropsWithFetch } from "../../routes"; import { IRoutePropsWithFetch } from "../../routes";
import PostHiddenSelect from "../common/post-hidden-select"; import PostHiddenSelect from "../common/post-hidden-select";
import { isBrowser, snapToTop } from "@utils/browser"; import { isBrowser, snapToTop } from "@utils/browser";
import { CommentSortSelect } from "../common/comment-sort-select";
interface HomeState { interface HomeState {
postsRes: RequestState<GetPostsResponse>; postsRes: RequestState<GetPostsResponse>;
@ -122,7 +124,7 @@ interface HomeState {
interface HomeProps { interface HomeProps {
listingType?: ListingType; listingType?: ListingType;
dataType: DataType; dataType: DataType;
sort: SortType; sort: PostSortType;
pageCursor?: PaginationCursor; pageCursor?: PaginationCursor;
showHidden?: StringBoolean; showHidden?: StringBoolean;
} }
@ -132,7 +134,7 @@ type HomeData = RouteDataResponse<{
commentsRes: GetCommentsResponse; commentsRes: GetCommentsResponse;
}>; }>;
function getRss(listingType: ListingType, sort: SortType) { function getRss(listingType: ListingType, sort: PostSortType) {
let rss: string | undefined = undefined; let rss: string | undefined = undefined;
const queryString = getQueryString({ sort }); const queryString = getQueryString({ sort });
@ -177,13 +179,13 @@ function getListingTypeFromQuery(
function getSortTypeFromQuery( function getSortTypeFromQuery(
type: string | undefined, type: string | undefined,
fallback: SortType, fallback: PostSortType,
): SortType { ): PostSortType {
return type ? (type as SortType) : fallback; return type ? (type as PostSortType) : fallback;
} }
type Fallbacks = { type Fallbacks = {
sort: SortType; sort: PostSortType;
listingType: ListingType; listingType: ListingType;
}; };
@ -204,7 +206,8 @@ export function getHomeQueryParams(
}, },
source, source,
{ {
sort: local_user?.default_sort_type ?? local_site.default_sort_type, sort:
local_user?.default_post_sort_type ?? local_site.default_post_sort_type,
listingType: listingType:
local_user?.default_listing_type ?? local_user?.default_listing_type ??
local_site.default_post_listing_type, local_site.default_post_listing_type,
@ -264,6 +267,7 @@ export class Home extends Component<HomeRouteProps, HomeState> {
super(props, context); super(props, context);
this.handleSortChange = this.handleSortChange.bind(this); this.handleSortChange = this.handleSortChange.bind(this);
this.handleCommentSortChange = this.handleCommentSortChange.bind(this);
this.handleListingTypeChange = this.handleListingTypeChange.bind(this); this.handleListingTypeChange = this.handleListingTypeChange.bind(this);
this.handleDataTypeChange = this.handleDataTypeChange.bind(this); this.handleDataTypeChange = this.handleDataTypeChange.bind(this);
this.handleShowHiddenChange = this.handleShowHiddenChange.bind(this); this.handleShowHiddenChange = this.handleShowHiddenChange.bind(this);
@ -311,9 +315,7 @@ export class Home extends Component<HomeRouteProps, HomeState> {
}; };
} }
this.state.tagline = getRandomFromList( this.state.tagline = this.state?.siteRes?.tagline?.content;
this.state?.siteRes?.taglines ?? [],
)?.content;
} }
async componentWillMount() { async componentWillMount() {
@ -726,7 +728,14 @@ export class Home extends Component<HomeRouteProps, HomeState> {
/> />
</div> </div>
<div className="col-auto"> <div className="col-auto">
<SortSelect sort={sort} onChange={this.handleSortChange} /> {this.props.dataType === DataType.Post ? (
<SortSelect sort={sort} onChange={this.handleSortChange} />
) : (
<CommentSortSelect
sort={postToCommentSortType(sort)}
onChange={this.handleCommentSortChange}
/>
)}
</div> </div>
<div className="col-auto ps-0"> <div className="col-auto ps-0">
{getRss( {getRss(
@ -799,10 +808,14 @@ export class Home extends Component<HomeRouteProps, HomeState> {
this.updateUrl({ pageCursor: nextPage }); this.updateUrl({ pageCursor: nextPage });
} }
handleSortChange(val: SortType) { handleSortChange(val: PostSortType) {
this.updateUrl({ sort: val, pageCursor: undefined }); this.updateUrl({ sort: val, pageCursor: undefined });
} }
handleCommentSortChange(val: CommentSortType) {
this.updateUrl({ sort: commentToPostSortType(val), pageCursor: undefined });
}
handleListingTypeChange(val: ListingType) { handleListingTypeChange(val: ListingType) {
this.updateUrl({ listingType: val, pageCursor: undefined }); this.updateUrl({ listingType: val, pageCursor: undefined });
} }

View file

@ -2,7 +2,6 @@ import { capitalizeFirstLetter, validInstanceTLD } from "@utils/helpers";
import { import {
Component, Component,
InfernoKeyboardEvent, InfernoKeyboardEvent,
InfernoMouseEvent,
InfernoNode, InfernoNode,
linkEvent, linkEvent,
} from "inferno"; } from "inferno";
@ -877,42 +876,6 @@ export class SiteForm extends Component<SiteFormProps, SiteFormState> {
this.setState(s => ((s.siteForm.legal_information = val), s)); this.setState(s => ((s.siteForm.legal_information = val), s));
} }
handleTaglineChange(i: SiteForm, index: number, val: string) {
const taglines = i.state.siteForm.taglines;
if (taglines) {
taglines[index] = val;
i.setState(i.state);
}
}
handleDeleteTaglineClick(
i: SiteForm,
index: number,
event: InfernoMouseEvent<HTMLButtonElement>,
) {
event.preventDefault();
const taglines = i.state.siteForm.taglines;
if (taglines) {
taglines.splice(index, 1);
i.state.siteForm.taglines = undefined;
i.setState(i.state);
i.state.siteForm.taglines = taglines;
i.setState(i.state);
}
}
handleAddTaglineClick(
i: SiteForm,
event: InfernoMouseEvent<HTMLButtonElement>,
) {
event.preventDefault();
if (!i.state.siteForm.taglines) {
i.state.siteForm.taglines = [];
}
i.state.siteForm.taglines.push("");
i.setState(i.state);
}
handleSiteApplicationQuestionChange(val: string) { handleSiteApplicationQuestionChange(val: string) {
this.setState(s => ((s.siteForm.application_question = val), s)); this.setState(s => ((s.siteForm.application_question = val), s));
} }

View file

@ -1,49 +1,84 @@
import { capitalizeFirstLetter } from "@utils/helpers"; import { capitalizeFirstLetter } from "@utils/helpers";
import { Component, InfernoMouseEvent, linkEvent } from "inferno"; import { Component, InfernoMouseEvent, linkEvent } from "inferno";
import { EditSite, Tagline } from "lemmy-js-client"; import { Tagline } from "lemmy-js-client";
import { I18NextService } from "../../services"; import { HttpService, I18NextService } from "../../services";
import { Icon, Spinner } from "../common/icon"; import { Icon, Spinner } from "../common/icon";
import { MarkdownTextArea } from "../common/markdown-textarea"; import { MarkdownTextArea } from "../common/markdown-textarea";
import { tippyMixin } from "../mixins/tippy-mixin"; import { tippyMixin } from "../mixins/tippy-mixin";
import { Paginator } from "../common/paginator";
import classNames from "classnames";
import { isBrowser } from "@utils/browser";
import { Prompt } from "inferno-router";
interface TaglineFormProps { interface EditableTagline {
taglines: Array<Tagline>; change?: "update" | "delete" | "create";
onSaveSite(form: EditSite): void; editMode?: boolean;
loading: boolean; tagline: Tagline;
}
function markForUpdate(editable: EditableTagline) {
if (editable.change !== "create") {
editable.change = "update";
}
} }
interface TaglineFormState { interface TaglineFormState {
taglines: Array<string>; taglines: Array<EditableTagline>;
editingRow?: number; page: number;
loading: boolean;
} }
@tippyMixin @tippyMixin
export class TaglineForm extends Component<TaglineFormProps, TaglineFormState> { export class TaglineForm extends Component<
Record<never, never>,
TaglineFormState
> {
state: TaglineFormState = { state: TaglineFormState = {
editingRow: undefined, taglines: [],
taglines: this.props.taglines.map(x => x.content), page: 1,
loading: false,
}; };
constructor(props: any, context: any) { constructor(props: any, context: any) {
super(props, context); super(props, context);
this.handlePageChange = this.handlePageChange.bind(this);
}
componentWillMount(): void {
if (isBrowser()) {
this.handlePageChange(1);
}
}
hasPendingChanges(): boolean {
return this.state.taglines.some(x => x.change);
} }
render() { render() {
return ( return (
<div className="tagline-form col-12"> <div className="tagline-form col-12">
<Prompt
message={I18NextService.i18n.t("block_leaving")}
when={this.hasPendingChanges()}
/>
<h1 className="h4 mb-4">{I18NextService.i18n.t("taglines")}</h1> <h1 className="h4 mb-4">{I18NextService.i18n.t("taglines")}</h1>
<div className="table-responsive col-12"> <div className="table-responsive col-12">
<table id="taglines_table" className="table table-sm table-hover"> <table
id="taglines_table"
className="table table-sm table-hover align-middle"
>
<thead className="pointer"> <thead className="pointer">
<th></th> <th></th>
<th style="width:60px"></th>
<th style="width:121px"></th> <th style="width:121px"></th>
</thead> </thead>
<tbody> <tbody>
{this.state.taglines.map((cv, index) => ( {this.state.taglines.map((cv, index) => (
<tr key={index}> <tr key={index}>
<td> <td>
{this.state.editingRow === index && ( {cv.editMode ? (
<MarkdownTextArea <MarkdownTextArea
initialContent={cv} initialContent={cv.tagline.content}
focus={true}
onContentChange={s => onContentChange={s =>
this.handleTaglineChange(this, index, s) this.handleTaglineChange(this, index, s)
} }
@ -51,8 +86,32 @@ export class TaglineForm extends Component<TaglineFormProps, TaglineFormState> {
allLanguages={[]} allLanguages={[]}
siteLanguages={[]} siteLanguages={[]}
/> />
) : (
<div>{cv.tagline.content}</div>
)}
</td>
<td
className={classNames("text-center", {
"border-info": cv.change === "update",
"border-danger": cv.change === "delete",
"border-warning": cv.change === "create",
})}
>
{cv.change === "update" && (
<span>
<Icon icon="transfer" />
</span>
)}
{cv.change === "delete" && (
<span>
<Icon icon="trash" />
</span>
)}
{cv.change === "create" && (
<span>
<Icon icon="add" inline />
</span>
)} )}
{this.state.editingRow !== index && <div>{cv}</div>}
</td> </td>
<td className="text-right"> <td className="text-right">
<button <button
@ -99,65 +158,153 @@ export class TaglineForm extends Component<TaglineFormProps, TaglineFormState> {
<button <button
onClick={linkEvent(this, this.handleSaveClick)} onClick={linkEvent(this, this.handleSaveClick)}
className="btn btn-secondary me-2" className="btn btn-secondary me-2"
disabled={this.props.loading} disabled={this.state.loading || !this.hasPendingChanges()}
> >
{this.props.loading ? ( {this.state.loading ? (
<Spinner /> <Spinner />
) : ( ) : (
capitalizeFirstLetter(I18NextService.i18n.t("save")) capitalizeFirstLetter(I18NextService.i18n.t("save"))
)} )}
</button> </button>
{this.hasPendingChanges() && (
<button
onClick={linkEvent(this, this.handleCancelClick)}
className="btn btn-secondary me-2"
>
{I18NextService.i18n.t("cancel")}
</button>
)}
</div> </div>
</div> </div>
<div>
<Paginator
page={this.state.page}
onChange={this.handlePageChange}
nextDisabled={false}
disabled={this.hasPendingChanges()}
/>
</div>
</div> </div>
</div> </div>
); );
} }
handleTaglineChange(i: TaglineForm, index: number, val: string) { handleTaglineChange(i: TaglineForm, index: number, val: string) {
if (i.state.taglines) { const editable = i.state.taglines[index];
i.setState(prev => ({ i.setState(() => {
...prev, markForUpdate(editable);
taglines: prev.taglines.map((tl, i) => (i === index ? val : tl)), const tagline: Tagline = editable.tagline;
})); tagline.content = val;
} });
} }
handleDeleteTaglineClick(d: { i: TaglineForm; index: number }, event: any) { async handleDeleteTaglineClick(
d: { i: TaglineForm; index: number },
event: any,
) {
event.preventDefault(); event.preventDefault();
d.i.setState(prev => ({ const editable = d.i.state.taglines[d.index];
...prev, if (editable.change === "create") {
taglines: prev.taglines.filter((_, i) => i !== d.index), // This drops the entry immediately, other deletes have to be saved.
editingRow: undefined, d.i.setState(prev => {
})); return { taglines: prev.taglines.filter(x => x !== editable) };
});
} else {
d.i.setState(() => {
editable.change = "delete";
editable.editMode = false;
});
}
} }
handleEditTaglineClick(d: { i: TaglineForm; index: number }, event: any) { handleEditTaglineClick(d: { i: TaglineForm; index: number }, event: any) {
event.preventDefault(); event.preventDefault();
if (d.i.state.editingRow === d.index) { const editable = d.i.state.taglines[d.index];
d.i.setState({ editingRow: undefined }); d.i.setState(prev => {
} else { prev.taglines
d.i.setState({ editingRow: d.index }); .filter(x => x !== editable)
} .forEach(x => {
} x.editMode = false;
});
async handleSaveClick(i: TaglineForm) { editable.editMode = !editable.editMode;
i.props.onSaveSite({
taglines: i.state.taglines,
}); });
} }
handleAddTaglineClick( async handleSaveClick(i: TaglineForm) {
const promises: Promise<any>[] = [];
for (const editable of i.state.taglines) {
if (editable.change === "update") {
promises.push(
HttpService.client.editTagline(editable.tagline).then(res => {
if (res.state === "success") {
i.setState(() => {
editable.change = undefined;
editable.tagline = res.data.tagline;
});
}
}),
);
} else if (editable.change === "delete") {
promises.push(
HttpService.client.deleteTagline(editable.tagline).then(res => {
if (res.state === "success") {
i.setState(() => {
editable.change = undefined;
return {
taglines: this.state.taglines.filter(x => x !== editable),
};
});
}
}),
);
} else if (editable.change === "create") {
promises.push(
HttpService.client.createTagline(editable.tagline).then(res => {
if (res.state === "success") {
i.setState(() => {
editable.change = undefined;
editable.tagline = res.data.tagline;
});
}
}),
);
}
}
await Promise.all(promises);
}
async handleCancelClick(i: TaglineForm) {
i.handlePageChange(i.state.page);
}
async handleAddTaglineClick(
i: TaglineForm, i: TaglineForm,
event: InfernoMouseEvent<HTMLButtonElement>, event: InfernoMouseEvent<HTMLButtonElement>,
) { ) {
event.preventDefault(); event.preventDefault();
const newTaglines = [...i.state.taglines]; i.setState(prev => {
newTaglines.push(""); prev.taglines.forEach(x => {
x.editMode = false;
i.setState({ });
taglines: newTaglines, prev.taglines.push({
editingRow: newTaglines.length - 1, tagline: { id: -1, content: "", published: "" },
change: "create",
editMode: true,
});
}); });
} }
async handlePageChange(val: number) {
this.setState({ loading: true });
const taglineRes = await HttpService.client.listTaglines({ page: val });
if (taglineRes.state === "success") {
this.setState({
page: val,
loading: false,
taglines: taglineRes.data.taglines.map(t => ({ tagline: t })),
});
} else {
this.setState({ loading: false });
}
}
} }

View file

@ -37,7 +37,7 @@ import {
RemovePost, RemovePost,
SaveComment, SaveComment,
SavePost, SavePost,
SortType, PostSortType,
TransferCommunity, TransferCommunity,
} from "lemmy-js-client"; } from "lemmy-js-client";
import { CommentViewType, PersonDetailsView } from "../../interfaces"; import { CommentViewType, PersonDetailsView } from "../../interfaces";
@ -53,7 +53,7 @@ interface PersonDetailsProps {
siteLanguages: number[]; siteLanguages: number[];
page: number; page: number;
limit: number; limit: number;
sort: SortType; sort: PostSortType;
enableDownvotes: boolean; enableDownvotes: boolean;
voteDisplayMode: LocalUserVoteDisplayMode; voteDisplayMode: LocalUserVoteDisplayMode;
enableNsfw: boolean; enableNsfw: boolean;

View file

@ -70,7 +70,7 @@ import {
RemovePost, RemovePost,
SaveComment, SaveComment,
SavePost, SavePost,
SortType, PostSortType,
SuccessResponse, SuccessResponse,
TransferCommunity, TransferCommunity,
RegistrationApplicationResponse, RegistrationApplicationResponse,
@ -127,7 +127,7 @@ interface ProfileState {
interface ProfileProps { interface ProfileProps {
view: PersonDetailsView; view: PersonDetailsView;
sort: SortType; sort: PostSortType;
page: number; page: number;
} }
@ -142,8 +142,8 @@ export function getProfileQueryParams(source?: string): ProfileProps {
); );
} }
function getSortTypeFromQuery(sort?: string): SortType { function getSortTypeFromQuery(sort?: string): PostSortType {
return sort ? (sort as SortType) : "New"; return sort ? (sort as PostSortType) : "New";
} }
function getViewFromProps(view?: string): PersonDetailsView { function getViewFromProps(view?: string): PersonDetailsView {
@ -956,7 +956,7 @@ export class Profile extends Component<ProfileRouteProps, ProfileState> {
this.updateUrl({ page }); this.updateUrl({ page });
} }
handleSortChange(sort: SortType) { handleSortChange(sort: PostSortType) {
this.updateUrl({ sort, page: 1 }); this.updateUrl({ sort, page: 1 });
} }

View file

@ -22,6 +22,7 @@ import {
BlockCommunityResponse, BlockCommunityResponse,
BlockInstanceResponse, BlockInstanceResponse,
BlockPersonResponse, BlockPersonResponse,
CommentSortType,
Community, Community,
GenerateTotpSecretResponse, GenerateTotpSecretResponse,
GetFederatedInstancesResponse, GetFederatedInstancesResponse,
@ -31,7 +32,7 @@ import {
ListingType, ListingType,
LoginResponse, LoginResponse,
Person, Person,
SortType, PostSortType,
SuccessResponse, SuccessResponse,
UpdateTotpResponse, UpdateTotpResponse,
} from "lemmy-js-client"; } from "lemmy-js-client";
@ -76,6 +77,7 @@ import { getHttpBaseInternal } from "../../utils/env";
import { IRoutePropsWithFetch } from "../../routes"; import { IRoutePropsWithFetch } from "../../routes";
import { RouteComponentProps } from "inferno-router/dist/Route"; import { RouteComponentProps } from "inferno-router/dist/Route";
import { simpleScrollMixin } from "../mixins/scroll-mixin"; import { simpleScrollMixin } from "../mixins/scroll-mixin";
import { CommentSortSelect } from "../common/comment-sort-select";
type SettingsData = RouteDataResponse<{ type SettingsData = RouteDataResponse<{
instancesRes: GetFederatedInstancesResponse; instancesRes: GetFederatedInstancesResponse;
@ -94,7 +96,8 @@ interface SettingsState {
blur_nsfw?: boolean; blur_nsfw?: boolean;
auto_expand?: boolean; auto_expand?: boolean;
theme?: string; theme?: string;
default_sort_type?: SortType; default_post_sort_type?: PostSortType;
default_comment_sort_type?: CommentSortType;
default_listing_type?: ListingType; default_listing_type?: ListingType;
interface_language?: string; interface_language?: string;
avatar?: string; avatar?: string;
@ -248,7 +251,9 @@ export class Settings extends Component<SettingsRouteProps, SettingsState> {
constructor(props: any, context: any) { constructor(props: any, context: any) {
super(props, context); super(props, context);
this.handleSortTypeChange = this.handleSortTypeChange.bind(this); this.handlePostSortTypeChange = this.handlePostSortTypeChange.bind(this);
this.handleCommentSortTypeChange =
this.handleCommentSortTypeChange.bind(this);
this.handleListingTypeChange = this.handleListingTypeChange.bind(this); this.handleListingTypeChange = this.handleListingTypeChange.bind(this);
this.handleBioChange = this.handleBioChange.bind(this); this.handleBioChange = this.handleBioChange.bind(this);
this.handleDiscussionLanguageChange = this.handleDiscussionLanguageChange =
@ -276,9 +281,9 @@ export class Settings extends Component<SettingsRouteProps, SettingsState> {
local_user: { local_user: {
show_nsfw, show_nsfw,
blur_nsfw, blur_nsfw,
auto_expand,
theme, theme,
default_sort_type, default_post_sort_type,
default_comment_sort_type,
default_listing_type, default_listing_type,
interface_language, interface_language,
show_avatars, show_avatars,
@ -313,9 +318,9 @@ export class Settings extends Component<SettingsRouteProps, SettingsState> {
...this.state.saveUserSettingsForm, ...this.state.saveUserSettingsForm,
show_nsfw, show_nsfw,
blur_nsfw, blur_nsfw,
auto_expand,
theme: theme ?? "browser", theme: theme ?? "browser",
default_sort_type, default_post_sort_type,
default_comment_sort_type,
default_listing_type, default_listing_type,
interface_language, interface_language,
discussion_languages: mui.discussion_languages, discussion_languages: mui.discussion_languages,
@ -905,14 +910,29 @@ export class Settings extends Component<SettingsRouteProps, SettingsState> {
</form> </form>
<form className="mb-3 row"> <form className="mb-3 row">
<label className="col-sm-3 col-form-label"> <label className="col-sm-3 col-form-label">
{I18NextService.i18n.t("sort_type")} {I18NextService.i18n.t("post_sort_type")}
</label> </label>
<div className="col-sm-9"> <div className="col-sm-9">
<SortSelect <SortSelect
sort={ sort={
this.state.saveUserSettingsForm.default_sort_type ?? "Active" this.state.saveUserSettingsForm.default_post_sort_type ??
"Active"
} }
onChange={this.handleSortTypeChange} onChange={this.handlePostSortTypeChange}
/>
</div>
</form>
<form className="mb-3 row">
<label className="col-sm-3 col-form-label">
{I18NextService.i18n.t("comment_sort_type")}
</label>
<div className="col-sm-9">
<CommentSortSelect
sort={
this.state.saveUserSettingsForm.default_comment_sort_type ??
"Hot"
}
onChange={this.handleCommentSortTypeChange}
/> />
</div> </div>
</form> </form>
@ -1580,8 +1600,16 @@ export class Settings extends Component<SettingsRouteProps, SettingsState> {
); );
} }
handleSortTypeChange(val: SortType) { handlePostSortTypeChange(val: PostSortType) {
this.setState(s => ((s.saveUserSettingsForm.default_sort_type = val), s)); this.setState(
s => ((s.saveUserSettingsForm.default_post_sort_type = val), s),
);
}
handleCommentSortTypeChange(val: CommentSortType) {
this.setState(
s => ((s.saveUserSettingsForm.default_comment_sort_type = val), s),
);
} }
handleListingTypeChange(val: ListingType) { handleListingTypeChange(val: ListingType) {
@ -1743,9 +1771,9 @@ export class Settings extends Component<SettingsRouteProps, SettingsState> {
local_user: { local_user: {
show_nsfw, show_nsfw,
blur_nsfw, blur_nsfw,
auto_expand,
theme, theme,
default_sort_type, default_post_sort_type,
default_comment_sort_type,
default_listing_type, default_listing_type,
interface_language, interface_language,
show_avatars, show_avatars,
@ -1782,11 +1810,11 @@ export class Settings extends Component<SettingsRouteProps, SettingsState> {
display_name, display_name,
bio, bio,
matrix_user_id, matrix_user_id,
auto_expand,
blur_nsfw, blur_nsfw,
bot_account, bot_account,
default_listing_type, default_listing_type,
default_sort_type, default_post_sort_type,
default_comment_sort_type,
discussion_languages: siteRes.data.my_user?.discussion_languages, discussion_languages: siteRes.data.my_user?.discussion_languages,
email, email,
interface_language, interface_language,

View file

@ -148,10 +148,10 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
UserService.Instance.myUserInfo && UserService.Instance.myUserInfo &&
!this.isoData.showAdultConsentModal !this.isoData.showAdultConsentModal
) { ) {
const { auto_expand, blur_nsfw } = const blur_nsfw =
UserService.Instance.myUserInfo.local_user_view.local_user; UserService.Instance.myUserInfo.local_user_view.local_user.blur_nsfw;
this.setState({ this.setState({
imageExpanded: auto_expand && !(blur_nsfw && this.postView.post.nsfw), imageExpanded: !(blur_nsfw && this.postView.post.nsfw),
}); });
} }

View file

@ -124,11 +124,12 @@ interface PostState {
lastCreatedCommentId?: CommentId; lastCreatedCommentId?: CommentId;
} }
const defaultCommentSort: CommentSortType = "Hot"; function getCommentSortTypeFromQuery(
source: string | undefined,
function getCommentSortTypeFromQuery(source?: string): CommentSortType { fallback: CommentSortType,
): CommentSortType {
if (!source) { if (!source) {
return defaultCommentSort; return fallback;
} }
switch (source) { switch (source) {
case "Hot": case "Hot":
@ -138,14 +139,21 @@ function getCommentSortTypeFromQuery(source?: string): CommentSortType {
case "Controversial": case "Controversial":
return source; return source;
default: default:
return defaultCommentSort; return fallback;
} }
} }
function getQueryStringFromCommentSortType( function getQueryStringFromCommentSortType(
sort: CommentSortType, sort: CommentSortType,
siteRes: GetSiteResponse,
): undefined | string { ): undefined | string {
if (sort === defaultCommentSort) { const myUserInfo = siteRes.my_user ?? UserService.Instance.myUserInfo;
const local_user = myUserInfo?.local_user_view.local_user;
const local_site = siteRes.site_view.local_site;
const defaultSort =
local_user?.default_comment_sort_type ??
local_site.default_comment_sort_type;
if (sort === defaultSort) {
return undefined; return undefined;
} }
return sort; return sort;
@ -185,14 +193,31 @@ interface PostProps {
view: CommentViewType; view: CommentViewType;
scrollToComments: boolean; scrollToComments: boolean;
} }
export function getPostQueryParams(source: string | undefined): PostProps {
return getQueryParams<PostProps>( type Fallbacks = {
sort: CommentSortType;
};
export function getPostQueryParams(
source: string | undefined,
siteRes: GetSiteResponse,
): PostProps {
const myUserInfo = siteRes.my_user ?? UserService.Instance.myUserInfo;
const local_user = myUserInfo?.local_user_view.local_user;
const local_site = siteRes.site_view.local_site;
return getQueryParams<PostProps, Fallbacks>(
{ {
scrollToComments: (s?: string) => !!s, scrollToComments: (s?: string) => !!s,
sort: getCommentSortTypeFromQuery, sort: getCommentSortTypeFromQuery,
view: getCommentViewTypeFromQuery, view: getCommentViewTypeFromQuery,
}, },
source, source,
{
sort:
local_user?.default_comment_sort_type ??
local_site.default_comment_sort_type,
},
); );
} }
@ -325,7 +350,7 @@ export class Post extends Component<PostRouteProps, PostState> {
}; };
const query: QueryParams<PostProps> = { const query: QueryParams<PostProps> = {
sort: getQueryStringFromCommentSortType(sort), sort: getQueryStringFromCommentSortType(sort, this.state.siteRes),
view: getQueryStringFromCommentView(view), view: getQueryStringFromCommentView(view),
}; };

View file

@ -46,7 +46,7 @@ import {
Search as SearchForm, Search as SearchForm,
SearchResponse, SearchResponse,
SearchType, SearchType,
SortType, PostSortType,
} from "lemmy-js-client"; } from "lemmy-js-client";
import { fetchLimit } from "../config"; import { fetchLimit } from "../config";
import { CommentViewType, InitialFetchRequest } from "../interfaces"; import { CommentViewType, InitialFetchRequest } from "../interfaces";
@ -76,9 +76,9 @@ import { isBrowser } from "@utils/browser";
interface SearchProps { interface SearchProps {
q?: string; q?: string;
type: SearchType; type: SearchType;
sort: SortType; sort: PostSortType;
listingType: ListingType; listingType: ListingType;
postTitleOnly?: boolean; titleOnly?: boolean;
communityId?: number; communityId?: number;
creatorId?: number; creatorId?: number;
page: number; page: number;
@ -124,7 +124,7 @@ export function getSearchQueryParams(source?: string): SearchProps {
type: getSearchTypeFromQuery, type: getSearchTypeFromQuery,
sort: getSortTypeFromQuery, sort: getSortTypeFromQuery,
listingType: getListingTypeFromQuery, listingType: getListingTypeFromQuery,
postTitleOnly: getBoolFromString, titleOnly: getBoolFromString,
communityId: getIdFromString, communityId: getIdFromString,
creatorId: getIdFromString, creatorId: getIdFromString,
page: getPageFromString, page: getPageFromString,
@ -139,8 +139,8 @@ function getSearchTypeFromQuery(type_?: string): SearchType {
return type_ ? (type_ as SearchType) : defaultSearchType; return type_ ? (type_ as SearchType) : defaultSearchType;
} }
function getSortTypeFromQuery(sort?: string): SortType { function getSortTypeFromQuery(sort?: string): PostSortType {
return sort ? (sort as SortType) : defaultSortType; return sort ? (sort as PostSortType) : defaultSortType;
} }
function getListingTypeFromQuery(listingType?: string): ListingType { function getListingTypeFromQuery(listingType?: string): ListingType {
@ -286,7 +286,7 @@ export class Search extends Component<SearchRouteProps, SearchState> {
this.handleCommunityFilterChange = this.handleCommunityFilterChange =
this.handleCommunityFilterChange.bind(this); this.handleCommunityFilterChange.bind(this);
this.handleCreatorFilterChange = this.handleCreatorFilterChange.bind(this); this.handleCreatorFilterChange = this.handleCreatorFilterChange.bind(this);
this.handlePostTitleChange = this.handlePostTitleChange.bind(this); this.handleTitleOnlyChange = this.handleTitleOnlyChange.bind(this);
// Only fetch the data if coming from another route // Only fetch the data if coming from another route
if (FirstLoadService.isFirstLoad) { if (FirstLoadService.isFirstLoad) {
@ -473,7 +473,7 @@ export class Search extends Component<SearchRouteProps, SearchState> {
type: searchType, type: searchType,
sort, sort,
listingType: listing_type, listingType: listing_type,
postTitleOnly: post_title_only, titleOnly: title_only,
communityId: community_id, communityId: community_id,
creatorId: creator_id, creatorId: creator_id,
page, page,
@ -519,7 +519,7 @@ export class Search extends Component<SearchRouteProps, SearchState> {
type_: searchType, type_: searchType,
sort, sort,
listing_type, listing_type,
post_title_only, title_only,
page, page,
limit: fetchLimit, limit: fetchLimit,
}; };
@ -595,7 +595,6 @@ export class Search extends Component<SearchRouteProps, SearchState> {
case "Comments": case "Comments":
return this.comments; return this.comments;
case "Posts": case "Posts":
case "Url":
return this.posts; return this.posts;
case "Communities": case "Communities":
return this.communities; return this.communities;
@ -641,7 +640,7 @@ export class Search extends Component<SearchRouteProps, SearchState> {
} }
get selects() { get selects() {
const { type, listingType, postTitleOnly, sort, communityId, creatorId } = const { type, listingType, titleOnly, sort, communityId, creatorId } =
this.props; this.props;
const { const {
communitySearchOptions, communitySearchOptions,
@ -684,15 +683,12 @@ export class Search extends Component<SearchRouteProps, SearchState> {
<div className="col"> <div className="col">
<input <input
className="btn-check" className="btn-check"
id="post-title-only" id="title-only"
type="checkbox" type="checkbox"
checked={postTitleOnly} checked={titleOnly}
onChange={this.handlePostTitleChange} onChange={this.handleTitleOnlyChange}
/> />
<label <label className="btn btn-outline-secondary" htmlFor="title-only">
className="btn btn-outline-secondary"
htmlFor="post-title-only"
>
{I18NextService.i18n.t("post_title_only")} {I18NextService.i18n.t("post_title_only")}
</label> </label>
</div> </div>
@ -1074,7 +1070,7 @@ export class Search extends Component<SearchRouteProps, SearchState> {
type, type,
sort, sort,
listingType, listingType,
postTitleOnly, titleOnly,
page, page,
} = props; } = props;
@ -1087,7 +1083,7 @@ export class Search extends Component<SearchRouteProps, SearchState> {
type_: type, type_: type,
sort, sort,
listing_type: listingType, listing_type: listingType,
post_title_only: postTitleOnly, title_only: titleOnly,
page, page,
limit: fetchLimit, limit: fetchLimit,
}); });
@ -1152,13 +1148,13 @@ export class Search extends Component<SearchRouteProps, SearchState> {
return this.searchInput.current?.value ?? this.props.q; return this.searchInput.current?.value ?? this.props.q;
} }
handleSortChange(sort: SortType) { handleSortChange(sort: PostSortType) {
this.updateUrl({ sort, page: 1, q: this.getQ() }); this.updateUrl({ sort, page: 1, q: this.getQ() });
} }
handlePostTitleChange(event: any) { handleTitleOnlyChange(event: any) {
const postTitleOnly = event.target.checked; const titleOnly = event.target.checked;
this.updateUrl({ postTitleOnly, q: this.getQ() }); this.updateUrl({ titleOnly, q: this.getQ() });
} }
handleTypeChange(i: Search, event: any) { handleTypeChange(i: Search, event: any) {
@ -1213,7 +1209,7 @@ export class Search extends Component<SearchRouteProps, SearchState> {
q, q,
type, type,
listingType, listingType,
postTitleOnly, titleOnly,
sort, sort,
communityId, communityId,
creatorId, creatorId,
@ -1227,7 +1223,7 @@ export class Search extends Component<SearchRouteProps, SearchState> {
q, q,
type: type, type: type,
listingType: listingType, listingType: listingType,
postTitleOnly: postTitleOnly?.toString(), titleOnly: titleOnly?.toString(),
communityId: communityId?.toString(), communityId: communityId?.toString(),
creatorId: creatorId?.toString(), creatorId: creatorId?.toString(),
page: page?.toString(), page: page?.toString(),

View file

@ -17,6 +17,8 @@ import markdown_it_highlightjs from "markdown-it-highlightjs/core";
import { Renderer, Token } from "markdown-it"; import { Renderer, Token } from "markdown-it";
import { instanceLinkRegex, relTags } from "./config"; import { instanceLinkRegex, relTags } from "./config";
import { lazyHighlightjs } from "./lazy-highlightjs"; import { lazyHighlightjs } from "./lazy-highlightjs";
import { HttpService } from "./services";
import { WrappedLemmyHttp } from "./services/HttpService";
let Tribute: any; let Tribute: any;
@ -32,7 +34,7 @@ export const mdLimited: MarkdownIt = new MarkdownIt("zero").enable([
"strikethrough", "strikethrough",
]); ]);
export const customEmojis: EmojiMartCategory[] = []; let customEmojis: EmojiMartCategory[] = [];
export let customEmojisLookup: Map<string, CustomEmojiView> = new Map< export let customEmojisLookup: Map<string, CustomEmojiView> = new Map<
string, string,
@ -204,14 +206,17 @@ export function setupMarkdown() {
) { ) {
//Provide custom renderer for our emojis to allow us to add a css class and force size dimensions on them. //Provide custom renderer for our emojis to allow us to add a css class and force size dimensions on them.
const item = tokens[idx] as any; const item = tokens[idx] as any;
let title = item.attrs.length >= 3 ? item.attrs[2][1] : ""; const url = item.attrs.length > 0 ? item.attrs[0][1] : "";
const altText = item.attrs.length > 1 ? item.attrs[1][1] : "";
const title = item.attrs.length > 2 ? item.attrs[2][1] : "";
const splitTitle = title.split(/ (.*)/, 2); const splitTitle = title.split(/ (.*)/, 2);
const isEmoji = splitTitle[0] === "emoji"; const isEmoji = splitTitle[0] === "emoji";
let shortcode: string | undefined;
if (isEmoji) { if (isEmoji) {
title = splitTitle[1]; shortcode = splitTitle[1];
} }
const customEmoji = customEmojisLookup.get(title); // customEmojisLookup is empty in SSR, CSR rerenders markdown anyway
const isLocalEmoji = customEmoji !== undefined; const isLocalEmoji = shortcode && customEmojisLookup.has(shortcode);
if (!isLocalEmoji) { if (!isLocalEmoji) {
const imgElement = const imgElement =
defaultImageRenderer?.(tokens, idx, options, env, self) ?? ""; defaultImageRenderer?.(tokens, idx, options, env, self) ?? "";
@ -222,10 +227,8 @@ export function setupMarkdown() {
} else return ""; } else return "";
} }
return `<img class="icon icon-emoji" src="${ return `<img class="icon icon-emoji" src="${
customEmoji!.custom_emoji.image_url url
}" title="${customEmoji!.custom_emoji.shortcode}" alt="${ }" title="${shortcode}" alt="${altText}"/>`;
customEmoji!.custom_emoji.alt_text
}"/>`;
}; };
md.renderer.rules.table_open = function () { md.renderer.rules.table_open = function () {
return '<table class="table">'; return '<table class="table">';
@ -247,12 +250,14 @@ export function setupMarkdown() {
}; };
} }
export function setupEmojiDataModel(custom_emoji_views: CustomEmojiView[]) { export function emojiMartCategories(
custom_emoji_views: CustomEmojiView[],
): EmojiMartCategory[] {
const groupedEmojis = groupBy( const groupedEmojis = groupBy(
custom_emoji_views, custom_emoji_views,
x => x.custom_emoji.category, x => x.custom_emoji.category,
); );
customEmojis.length = 0; const customEmojis: EmojiMartCategory[] = [];
for (const [category, emojis] of Object.entries(groupedEmojis)) { for (const [category, emojis] of Object.entries(groupedEmojis)) {
customEmojis.push({ customEmojis.push({
id: category, id: category,
@ -265,63 +270,24 @@ export function setupEmojiDataModel(custom_emoji_views: CustomEmojiView[]) {
})), })),
}); });
} }
return customEmojis;
}
export async function setupEmojiDataModel(
client: WrappedLemmyHttp = HttpService.client,
): Promise<boolean> {
const emojisRes = await client.listCustomEmojis({
ignore_page_limits: true,
});
if (emojisRes.state !== "success") {
return false;
}
const custom_emoji_views = emojisRes.data.custom_emojis;
customEmojis = emojiMartCategories(custom_emoji_views);
customEmojisLookup = new Map( customEmojisLookup = new Map(
custom_emoji_views.map(view => [view.custom_emoji.shortcode, view]), custom_emoji_views.map(view => [view.custom_emoji.shortcode, view]),
); );
} return true;
export function updateEmojiDataModel(custom_emoji_view: CustomEmojiView) {
const emoji: EmojiMartCustomEmoji = {
id: custom_emoji_view.custom_emoji.shortcode,
name: custom_emoji_view.custom_emoji.shortcode,
keywords: custom_emoji_view.keywords.map(x => x.keyword),
skins: [{ src: custom_emoji_view.custom_emoji.image_url }],
};
const categoryIndex = customEmojis.findIndex(
x => x.id === custom_emoji_view.custom_emoji.category,
);
if (categoryIndex === -1) {
customEmojis.push({
id: custom_emoji_view.custom_emoji.category,
name: custom_emoji_view.custom_emoji.category,
emojis: [emoji],
});
} else {
const emojiIndex = customEmojis[categoryIndex].emojis.findIndex(
x => x.id === custom_emoji_view.custom_emoji.shortcode,
);
if (emojiIndex === -1) {
customEmojis[categoryIndex].emojis.push(emoji);
} else {
customEmojis[categoryIndex].emojis[emojiIndex] = emoji;
}
}
customEmojisLookup.set(
custom_emoji_view.custom_emoji.shortcode,
custom_emoji_view,
);
}
export function removeFromEmojiDataModel(id: number) {
let view: CustomEmojiView | undefined;
for (const item of customEmojisLookup.values()) {
if (item.custom_emoji.id === id) {
view = item;
break;
}
}
if (!view) return;
const categoryIndex = customEmojis.findIndex(
x => x.id === view?.custom_emoji.category,
);
const emojiIndex = customEmojis[categoryIndex].emojis.findIndex(
x => x.id === view?.custom_emoji.shortcode,
);
customEmojis[categoryIndex].emojis = customEmojis[
categoryIndex
].emojis.splice(emojiIndex, 1);
customEmojisLookup.delete(view?.custom_emoji.shortcode);
} }
export function getEmojiMart( export function getEmojiMart(
@ -329,9 +295,9 @@ export function getEmojiMart(
customPickerOptions: any = {}, customPickerOptions: any = {},
) { ) {
const pickerOptions = { const pickerOptions = {
...customPickerOptions,
onEmojiSelect: onEmojiSelect, onEmojiSelect: onEmojiSelect,
custom: customEmojis, custom: customEmojis,
...customPickerOptions,
}; };
return new Picker(pickerOptions); return new Picker(pickerOptions);
} }
@ -416,7 +382,7 @@ export async function setupTribute() {
}); });
} }
interface EmojiMartCategory { export interface EmojiMartCategory {
id: string; id: string;
name: string; name: string;
emojis: EmojiMartCustomEmoji[]; emojis: EmojiMartCustomEmoji[];

View file

@ -0,0 +1,21 @@
import { CommentSortType, PostSortType } from "lemmy-js-client";
function assertType<T>(_: T) {}
export default function commentToPostSortType(
sort: CommentSortType,
): PostSortType {
switch (sort) {
case "Hot":
case "New":
case "Old":
case "Controversial":
return sort;
case "Top":
return "TopAll";
default: {
assertType<never>(sort);
return "Hot";
}
}
}

View file

@ -1,31 +0,0 @@
import { CommentSortType, SortType } from "lemmy-js-client";
export default function convertCommentSortType(
sort: SortType,
): CommentSortType {
switch (sort) {
case "TopAll":
case "TopHour":
case "TopSixHour":
case "TopTwelveHour":
case "TopDay":
case "TopWeek":
case "TopMonth":
case "TopThreeMonths":
case "TopSixMonths":
case "TopNineMonths":
case "TopYear": {
return "Top";
}
case "New": {
return "New";
}
case "Hot":
case "Active": {
return "Hot";
}
default: {
return "Hot";
}
}
}

View file

@ -1,11 +1,11 @@
import buildCommentsTree from "./build-comments-tree"; import buildCommentsTree from "./build-comments-tree";
import { colorList } from "./color-list"; import { colorList } from "./color-list";
import commentToPostSortType from "./comment-to-post-sort-type";
import commentsToFlatNodes from "./comments-to-flat-nodes"; import commentsToFlatNodes from "./comments-to-flat-nodes";
import communityRSSUrl from "./community-rss-url"; import communityRSSUrl from "./community-rss-url";
import communitySearch from "./community-search"; import communitySearch from "./community-search";
import communitySelectName from "./community-select-name"; import communitySelectName from "./community-select-name";
import communityToChoice from "./community-to-choice"; import communityToChoice from "./community-to-choice";
import convertCommentSortType from "./convert-comment-sort-type";
import editComment from "./edit-comment"; import editComment from "./edit-comment";
import editCommentReply from "./edit-comment-reply"; import editCommentReply from "./edit-comment-reply";
import editCommentReport from "./edit-comment-report"; import editCommentReport from "./edit-comment-report";
@ -59,12 +59,12 @@ import isAnonymousPath from "./is-anonymous-path";
export { export {
buildCommentsTree, buildCommentsTree,
colorList, colorList,
commentToPostSortType,
commentsToFlatNodes, commentsToFlatNodes,
communityRSSUrl, communityRSSUrl,
communitySearch, communitySearch,
communitySelectName, communitySelectName,
communityToChoice, communityToChoice,
convertCommentSortType,
editComment, editComment,
editCommentReply, editCommentReply,
editCommentReport, editCommentReport,

View file

@ -1,11 +1,8 @@
import { GetSiteResponse } from "lemmy-js-client"; import { GetSiteResponse } from "lemmy-js-client";
import { setupEmojiDataModel, setupMarkdown } from "../../markdown"; import { setupMarkdown } from "../../markdown";
import { UserService } from "../../services"; import { UserService } from "../../services";
export default function initializeSite(site?: GetSiteResponse) { export default function initializeSite(site?: GetSiteResponse) {
UserService.Instance.myUserInfo = site?.my_user; UserService.Instance.myUserInfo = site?.my_user;
if (site) {
setupEmojiDataModel(site.custom_emojis ?? []);
}
setupMarkdown(); setupMarkdown();
} }

View file

@ -1,16 +1,39 @@
import { CommentSortType, SortType } from "lemmy-js-client"; import { CommentSortType, PostSortType } from "lemmy-js-client";
export default function postToCommentSortType(sort: SortType): CommentSortType { function assertType<T>(_: T) {}
export default function postToCommentSortType(
sort: PostSortType,
): CommentSortType {
switch (sort) { switch (sort) {
case "Active":
case "Hot": case "Hot":
return "Hot";
case "New": case "New":
case "NewComments":
return "New";
case "Old": case "Old":
return "Old"; case "Controversial": {
default: return sort;
}
case "TopAll":
case "TopHour":
case "TopSixHour":
case "TopTwelveHour":
case "TopDay":
case "TopWeek":
case "TopMonth":
case "TopThreeMonths":
case "TopSixMonths":
case "TopNineMonths":
case "TopYear": {
return "Top"; return "Top";
}
case "NewComments":
case "MostComments":
case "Scaled":
case "Active": {
return "Hot";
}
default: {
assertType<never>(sort);
return "Hot";
}
} }
} }