Use mixins and decorators for scroll restoration and tippy cleanup (#2415)

* Enable @babel/plugin-proposal-decorators

Dependency already exists

* Use tippy.js delegate addon, cleanup tippy instances from a mixin.

The delegate addon creates tippy instances from mouse and touch events
with a matching `event.target`. This is initially significantly cheaper
than creating all instances at once. The addon keeps all created tippy
instances alive until it is destroyed itself.

`tippyMixin` destroys the addon instance after every render, as long as
all instances are hidden. This drops some tippy instances that may have
to be recreated later (e.g when the mouse moves over the trigger again),
but is otherwise fairly cheap (creates one tippy instance).

* Restore scroll positions when resource loading settles.

The history module generates a random string (`location.key`) for every
browser history entry. The names for saved positions include this key.
The position is saved before a route component unmounts or before the
`location.key` changes.

The `scrollMixin` tires to restore the scroll position after every
change of `location.key`. It only does so after the first render for
which the route components `loadingSettled()` returns true.

Things like `scrollToComments` should only scroll when `history.action`
is not "POP".

* Drop individual scrollTo calls

* Scroll to comments without reloading post

---------

Co-authored-by: SleeplessOne1917 <28871516+SleeplessOne1917@users.noreply.github.com>
This commit is contained in:
matc-pub 2024-04-11 19:18:07 +02:00 committed by GitHub
parent b983071e79
commit e48590b9d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
62 changed files with 571 additions and 143 deletions

View file

@ -13,7 +13,12 @@
["@babel/typescript", { "isTSX": true, "allExtensions": true }]
],
"plugins": [
["@babel/plugin-proposal-decorators", { "version": "legacy" }],
[
"@babel/plugin-transform-runtime",
// version defaults to 7.0.0 for which non-legacy decorators produce duplicate code
{ "version": "^7.24.3" }
],
["babel-plugin-inferno", { "imports": true }],
["@babel/plugin-transform-class-properties", { "loose": true }]
]

View file

@ -21,6 +21,10 @@
"@typescript-eslint/explicit-module-boundary-types": 0,
"@typescript-eslint/no-empty-function": 0,
"@typescript-eslint/no-non-null-assertion": 0,
"@typescript-eslint/no-unused-vars": [
"error",
{ "argsIgnorePattern": "^_" }
],
"arrow-body-style": 0,
"curly": 0,
"eol-last": 0,

View file

@ -16,6 +16,8 @@ async function startClient() {
verifyDynamicImports(true).then(x => console.log(x));
};
window.history.scrollRestoration = "manual";
initializeSite(window.isoData.site_res);
lazyHighlightjs.enableLazyLoading();

View file

@ -13,15 +13,25 @@ import { Navbar } from "./navbar";
import "./styles.scss";
import { Theme } from "./theme";
import AnonymousGuard from "../common/anonymous-guard";
import { destroyTippy, setupTippy } from "../../tippy";
export class App extends Component<any, any> {
private isoData: IsoDataOptionalSite = setIsoData(this.context);
private readonly mainContentRef: RefObject<HTMLElement>;
private readonly rootRef = createRef<HTMLDivElement>();
constructor(props: any, context: any) {
super(props, context);
this.mainContentRef = createRef();
}
componentDidMount(): void {
setupTippy(this.rootRef);
}
componentWillUnmount(): void {
destroyTippy();
}
handleJumpToContent(event) {
event.preventDefault();
this.mainContentRef.current?.focus();
@ -34,7 +44,7 @@ export class App extends Component<any, any> {
return (
<>
<Provider i18next={I18NextService.i18n}>
<div id="app" className="lemmy-site">
<div id="app" className="lemmy-site" ref={this.rootRef}>
<button
type="button"
className="btn skip-link bg-light position-absolute start-0 z-3"

View file

@ -15,6 +15,7 @@ import { toast } from "../../toast";
import { Icon } from "../common/icon";
import { PictrsImage } from "../common/pictrs-image";
import { Subscription } from "rxjs";
import { tippyMixin } from "../mixins/tippy-mixin";
interface NavbarProps {
siteRes?: GetSiteResponse;
@ -42,6 +43,7 @@ function handleLogOut(i: Navbar) {
handleCollapseClick(i);
}
@tippyMixin
export class Navbar extends Component<NavbarProps, NavbarState> {
collapseButtonRef = createRef<HTMLButtonElement>();
mobileMenuRef = createRef<HTMLDivElement>();

View file

@ -42,7 +42,7 @@ import {
} from "../../interfaces";
import { mdToHtml, mdToHtmlNoImages } from "../../markdown";
import { I18NextService, UserService } from "../../services";
import { setupTippy } from "../../tippy";
import { tippyMixin } from "../mixins/tippy-mixin";
import { Icon, Spinner } from "../common/icon";
import { MomentTime } from "../common/moment-time";
import { UserBadges } from "../common/user-badges";
@ -117,6 +117,7 @@ function handleToggleViewSource(i: CommentNode) {
}));
}
@tippyMixin
export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
state: CommentNodeState = {
showReply: false,
@ -607,12 +608,10 @@ export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
handleCommentCollapse(i: CommentNode) {
i.setState({ collapsed: !i.state.collapsed });
setupTippy();
}
handleShowAdvanced(i: CommentNode) {
i.setState({ showAdvanced: !i.state.showAdvanced });
setupTippy();
}
async handleSaveComment() {

View file

@ -11,6 +11,7 @@ import { Icon, Spinner } from "../common/icon";
import { PersonListing } from "../person/person-listing";
import { CommentNode } from "./comment-node";
import { EMPTY_REQUEST } from "../../services/HttpService";
import { tippyMixin } from "../mixins/tippy-mixin";
interface CommentReportProps {
report: CommentReportView;
@ -21,6 +22,7 @@ interface CommentReportState {
loading: boolean;
}
@tippyMixin
export class CommentReport extends Component<
CommentReportProps,
CommentReportState

View file

@ -1,6 +1,7 @@
import { Component, linkEvent } from "inferno";
import { Icon, Spinner } from "../icon";
import classNames from "classnames";
import { tippyMixin } from "../../mixins/tippy-mixin";
interface ActionButtonPropsBase {
label: string;
@ -34,6 +35,7 @@ async function handleClick(i: ActionButton) {
i.setState({ loading: false });
}
@tippyMixin
export default class ActionButton extends Component<
ActionButtonProps,
ActionButtonState

View file

@ -20,6 +20,7 @@ import ViewVotesModal from "../view-votes-modal";
import ModActionFormModal, { BanUpdateForm } from "../mod-action-form-modal";
import { BanType, CommentNodeView, PurgeType } from "../../../interfaces";
import { getApubName, hostname } from "@utils/helpers";
import { tippyMixin } from "../../mixins/tippy-mixin";
interface ContentActionDropdownPropsBase {
onSave: () => Promise<void>;
@ -76,6 +77,7 @@ type ContentActionDropdownState = {
mounted: boolean;
} & { [key in DialogType]: boolean };
@tippyMixin
export default class ContentActionDropdown extends Component<
ContentActionDropdownProps,
ContentActionDropdownState

View file

@ -2,6 +2,7 @@ import { Component, linkEvent } from "inferno";
import { I18NextService } from "../../services";
import { EmojiMart } from "./emoji-mart";
import { Icon } from "./icon";
import { tippyMixin } from "../mixins/tippy-mixin";
interface EmojiPickerProps {
onEmojiClick?(val: any): any;
@ -16,6 +17,7 @@ function closeEmojiMartOnEsc(i, event): void {
event.key === "Escape" && i.setState({ showPicker: false });
}
@tippyMixin
export class EmojiPicker extends Component<EmojiPickerProps, EmojiPickerState> {
private emptyState: EmojiPickerState = {
showPicker: false,

View file

@ -3,7 +3,7 @@ import { numToSI, randomStr } from "@utils/helpers";
import autosize from "autosize";
import classNames from "classnames";
import { NoOptionI18nKeys } from "i18next";
import { Component, linkEvent } from "inferno";
import { Component, InfernoNode, linkEvent } from "inferno";
import { Prompt } from "inferno-router";
import { Language } from "lemmy-js-client";
import {
@ -15,7 +15,7 @@ import {
} from "../../config";
import { customEmojisLookup, mdToHtml, setupTribute } from "../../markdown";
import { HttpService, I18NextService, UserService } from "../../services";
import { setupTippy } from "../../tippy";
import { tippyMixin } from "../mixins/tippy-mixin";
import { pictrsDeleteToast, toast } from "../../toast";
import { EmojiPicker } from "./emoji-picker";
import { Icon, Spinner } from "./icon";
@ -68,6 +68,7 @@ interface MarkdownTextAreaState {
submitted: boolean;
}
@tippyMixin
export class MarkdownTextArea extends Component<
MarkdownTextAreaProps,
MarkdownTextAreaState
@ -111,13 +112,12 @@ export class MarkdownTextArea extends Component<
if (this.props.focus) {
textarea.focus();
}
// TODO this is slow for some reason
setupTippy();
}
}
componentWillReceiveProps(nextProps: MarkdownTextAreaProps) {
componentWillReceiveProps(
nextProps: MarkdownTextAreaProps & { children?: InfernoNode },
) {
if (nextProps.finished) {
this.setState({
previewMode: false,

View file

@ -3,6 +3,7 @@ import { format, parseISO } from "date-fns";
import { Component } from "inferno";
import { I18NextService } from "../../services";
import { Icon } from "./icon";
import { tippyMixin } from "../mixins/tippy-mixin";
interface MomentTimeProps {
published: string;
@ -16,6 +17,7 @@ function formatDate(input: string) {
return format(parsed, "PPPPpppp");
}
@tippyMixin
export class MomentTime extends Component<MomentTimeProps, any> {
constructor(props: any, context: any) {
super(props, context);

View file

@ -5,6 +5,7 @@ import { Component, FormEventHandler, linkEvent } from "inferno";
import { NavLink } from "inferno-router";
import { I18NextService } from "../../services";
import { Icon } from "./icon";
import { tippyMixin } from "../mixins/tippy-mixin";
interface PasswordInputProps {
id: string;
@ -55,6 +56,7 @@ function handleToggleShow(i: PasswordInput) {
}));
}
@tippyMixin
class PasswordInput extends Component<PasswordInputProps, PasswordInputState> {
state: PasswordInputState = {
show: false,

View file

@ -1,6 +1,7 @@
import classNames from "classnames";
import { Component } from "inferno";
import { I18NextService } from "../../services";
import { tippyMixin } from "../mixins/tippy-mixin";
interface UserBadgesProps {
isBanned?: boolean;
@ -12,7 +13,7 @@ interface UserBadgesProps {
classNames?: string;
}
export function getRoleLabelPill({
function getRoleLabelPill({
label,
tooltip,
classes,
@ -34,6 +35,7 @@ export function getRoleLabelPill({
);
}
@tippyMixin
export class UserBadges extends Component<UserBadgesProps> {
render() {
return (

View file

@ -1,7 +1,7 @@
import { newVote, showScores } from "@utils/app";
import { numToSI } from "@utils/helpers";
import classNames from "classnames";
import { Component, linkEvent } from "inferno";
import { Component, InfernoNode, linkEvent } from "inferno";
import {
CommentAggregates,
CreateCommentLike,
@ -11,6 +11,7 @@ import {
import { VoteContentType, VoteType } from "../../interfaces";
import { I18NextService, UserService } from "../../services";
import { Icon, Spinner } from "../common/icon";
import { tippyMixin } from "../mixins/tippy-mixin";
interface VoteButtonsProps {
voteContentType: VoteContentType;
@ -82,6 +83,7 @@ const handleDownvote = (i: VoteButtons) => {
}
};
@tippyMixin
export class VoteButtonsCompact extends Component<
VoteButtonsProps,
VoteButtonsState
@ -95,7 +97,9 @@ export class VoteButtonsCompact extends Component<
super(props, context);
}
componentWillReceiveProps(nextProps: VoteButtonsProps) {
componentWillReceiveProps(
nextProps: VoteButtonsProps & { children?: InfernoNode },
) {
if (this.props !== nextProps) {
this.setState({
upvoteLoading: false,
@ -166,6 +170,7 @@ export class VoteButtonsCompact extends Component<
}
}
@tippyMixin
export class VoteButtons extends Component<VoteButtonsProps, VoteButtonsState> {
state: VoteButtonsState = {
upvoteLoading: false,
@ -176,7 +181,9 @@ export class VoteButtons extends Component<VoteButtonsProps, VoteButtonsState> {
super(props, context);
}
componentWillReceiveProps(nextProps: VoteButtonsProps) {
componentWillReceiveProps(
nextProps: VoteButtonsProps & { children?: InfernoNode },
) {
if (this.props !== nextProps) {
this.setState({
upvoteLoading: false,

View file

@ -4,6 +4,7 @@ import {
getQueryParams,
getQueryString,
numToSI,
resourcesSettled,
} from "@utils/helpers";
import type { QueryParams } from "@utils/types";
import { RouteDataResponse } from "@utils/types";
@ -38,6 +39,7 @@ import { SubscribeButton } from "../common/subscribe-button";
import { getHttpBaseInternal } from "../../utils/env";
import { RouteComponentProps } from "inferno-router/dist/Route";
import { IRoutePropsWithFetch } from "../../routes";
import { scrollMixin } from "../mixins/scroll-mixin";
type CommunitiesData = RouteDataResponse<{
listCommunitiesResponse: ListCommunitiesResponse;
@ -84,6 +86,7 @@ export type CommunitiesFetchConfig = IRoutePropsWithFetch<
CommunitiesProps
>;
@scrollMixin
export class Communities extends Component<
CommunitiesRouteProps,
CommunitiesState
@ -96,6 +99,10 @@ export class Communities extends Component<
isIsomorphic: false,
};
loadingSettled() {
return resourcesSettled([this.state.listCommunitiesResponse]);
}
constructor(props: CommunitiesRouteProps, context: any) {
super(props, context);
this.handlePageChange = this.handlePageChange.bind(this);
@ -374,8 +381,6 @@ export class Communities extends Component<
page,
}),
});
window.scrollTo(0, 0);
}
findAndUpdateCommunity(res: RequestState<CommunityResponse>) {

View file

@ -13,6 +13,7 @@ import { Icon, Spinner } from "../common/icon";
import { ImageUploadForm } from "../common/image-upload-form";
import { LanguageSelect } from "../common/language-select";
import { MarkdownTextArea } from "../common/markdown-textarea";
import { tippyMixin } from "../mixins/tippy-mixin";
interface CommunityFormProps {
community_view?: CommunityView; // If a community is given, that means this is an edit
@ -40,6 +41,7 @@ interface CommunityFormState {
submitted: boolean;
}
@tippyMixin
export class CommunityForm extends Component<
CommunityFormProps,
CommunityFormState

View file

@ -14,7 +14,12 @@ import {
updateCommunityBlock,
updatePersonBlock,
} from "@utils/app";
import { getQueryParams, getQueryString } from "@utils/helpers";
import {
getQueryParams,
getQueryString,
resourcesSettled,
} from "@utils/helpers";
import { scrollMixin } from "../mixins/scroll-mixin";
import type { QueryParams } from "@utils/types";
import { RouteDataResponse } from "@utils/types";
import { Component, RefObject, createRef, linkEvent } from "inferno";
@ -87,7 +92,7 @@ import {
RequestState,
wrapClient,
} from "../../services/HttpService";
import { setupTippy } from "../../tippy";
import { tippyMixin } from "../mixins/tippy-mixin";
import { toast } from "../../toast";
import { CommentNodes } from "../comment/comment-nodes";
import { BannerIconHeader } from "../common/banner-icon-header";
@ -172,6 +177,8 @@ export type CommunityFetchConfig = IRoutePropsWithFetch<
CommunityProps
>;
@scrollMixin
@tippyMixin
export class Community extends Component<CommunityRouteProps, State> {
private isoData = setIsoData<CommunityData>(this.context);
state: State = {
@ -184,6 +191,16 @@ export class Community extends Component<CommunityRouteProps, State> {
isIsomorphic: false,
};
private readonly mainContentRef: RefObject<HTMLElement>;
loadingSettled() {
return resourcesSettled([
this.state.communityRes,
this.props.dataType === DataType.Post
? this.state.postsRes
: this.state.commentsRes,
]);
}
constructor(props: CommunityRouteProps, context: any) {
super(props, context);
@ -253,8 +270,6 @@ export class Community extends Component<CommunityRouteProps, State> {
if (!this.state.isIsomorphic) {
await Promise.all([this.fetchCommunity(), this.fetchData()]);
}
setupTippy();
}
static async fetchInitialData({
@ -586,17 +601,14 @@ export class Community extends Component<CommunityRouteProps, State> {
handlePageNext(nextPage: PaginationCursor) {
this.updateUrl({ pageCursor: nextPage });
window.scrollTo(0, 0);
}
handleSortChange(sort: SortType) {
this.updateUrl({ sort, pageCursor: undefined });
window.scrollTo(0, 0);
}
handleDataTypeChange(dataType: DataType) {
this.updateUrl({ dataType, pageCursor: undefined });
window.scrollTo(0, 0);
}
handleShowSidebarMobile(i: Community) {
@ -649,8 +661,6 @@ export class Community extends Component<CommunityRouteProps, State> {
}),
});
}
setupTippy();
}
async handleDeleteCommunity(form: DeleteCommunity) {

View file

@ -7,13 +7,19 @@ import {
import { HttpService, I18NextService } from "../../services";
import { HtmlTags } from "../common/html-tags";
import { CommunityForm } from "./community-form";
import { simpleScrollMixin } from "../mixins/scroll-mixin";
import { RouteComponentProps } from "inferno-router/dist/Route";
interface CreateCommunityState {
siteRes: GetSiteResponse;
loading: boolean;
}
export class CreateCommunity extends Component<any, CreateCommunityState> {
@simpleScrollMixin
export class CreateCommunity extends Component<
RouteComponentProps<Record<string, never>>,
CreateCommunityState
> {
private isoData = setIsoData(this.context);
state: CreateCommunityState = {
siteRes: this.isoData.site_res,

View file

@ -25,6 +25,7 @@ import { SubscribeButton } from "../common/subscribe-button";
import { CommunityForm } from "../community/community-form";
import { CommunityLink } from "../community/community-link";
import { PersonListing } from "../person/person-listing";
import { tippyMixin } from "../mixins/tippy-mixin";
interface SidebarProps {
community_view: CommunityView;
@ -60,6 +61,7 @@ interface SidebarState {
purgeCommunityLoading: boolean;
}
@tippyMixin
export class Sidebar extends Component<SidebarProps, SidebarState> {
state: SidebarState = {
showEdit: false,

View file

@ -1,5 +1,6 @@
import { fetchThemeList, setIsoData, showLocal } from "@utils/app";
import { capitalizeFirstLetter } from "@utils/helpers";
import { capitalizeFirstLetter, resourcesSettled } from "@utils/helpers";
import { scrollMixin } from "../mixins/scroll-mixin";
import { RouteDataResponse } from "@utils/types";
import classNames from "classnames";
import { Component, linkEvent } from "inferno";
@ -62,6 +63,7 @@ export type AdminSettingsFetchConfig = IRoutePropsWithFetch<
Record<string, never>
>;
@scrollMixin
export class AdminSettings extends Component<
AdminSettingsRouteProps,
AdminSettingsState
@ -79,6 +81,10 @@ export class AdminSettings extends Component<
isIsomorphic: false,
};
loadingSettled() {
return resourcesSettled([this.state.bannedRes, this.state.instancesRes]);
}
constructor(props: any, context: any) {
super(props, context);

View file

@ -13,6 +13,7 @@ import { pictrsDeleteToast, toast } from "../../toast";
import { EmojiMart } from "../common/emoji-mart";
import { Icon, Spinner } from "../common/icon";
import { Paginator } from "../common/paginator";
import { tippyMixin } from "../mixins/tippy-mixin";
interface EmojiFormProps {
onEdit(form: EditCustomEmoji): void;
@ -38,6 +39,7 @@ interface CustomEmojiViewForm {
loading: boolean;
}
@tippyMixin
export class EmojiForm extends Component<EmojiFormProps, EmojiFormState> {
private isoData = setIsoData(this.context);
private itemsPerPage = 15;

View file

@ -17,7 +17,9 @@ import {
getQueryParams,
getQueryString,
getRandomFromList,
resourcesSettled,
} from "@utils/helpers";
import { scrollMixin } from "../mixins/scroll-mixin";
import { canCreateCommunity } from "@utils/roles";
import type { QueryParams } from "@utils/types";
import { RouteDataResponse } from "@utils/types";
@ -87,7 +89,7 @@ import {
RequestState,
wrapClient,
} from "../../services/HttpService";
import { setupTippy } from "../../tippy";
import { tippyMixin } from "../mixins/tippy-mixin";
import { toast } from "../../toast";
import { CommentNodes } from "../comment/comment-nodes";
import { DataTypeSelect } from "../common/data-type-select";
@ -107,6 +109,7 @@ import {
} from "../common/loading-skeleton";
import { RouteComponentProps } from "inferno-router/dist/Route";
import { IRoutePropsWithFetch } from "../../routes";
import { snapToTop } from "@utils/browser";
interface HomeState {
postsRes: RequestState<GetPostsResponse>;
@ -116,7 +119,6 @@ interface HomeState {
showTrendingMobile: boolean;
showSidebarMobile: boolean;
subscribedCollapsed: boolean;
scrolled: boolean;
tagline?: string;
siteRes: GetSiteResponse;
finished: Map<CommentId, boolean | undefined>;
@ -253,13 +255,14 @@ export type HomeFetchConfig = IRoutePropsWithFetch<
HomeProps
>;
@scrollMixin
@tippyMixin
export class Home extends Component<HomeRouteProps, HomeState> {
private isoData = setIsoData<HomeData>(this.context);
state: HomeState = {
postsRes: EMPTY_REQUEST,
commentsRes: EMPTY_REQUEST,
trendingCommunitiesRes: EMPTY_REQUEST,
scrolled: true,
siteRes: this.isoData.site_res,
showSubscribedMobile: false,
showTrendingMobile: false,
@ -269,6 +272,15 @@ export class Home extends Component<HomeRouteProps, HomeState> {
isIsomorphic: false,
};
loadingSettled(): boolean {
return resourcesSettled([
this.state.trendingCommunitiesRes,
this.props.dataType === DataType.Post
? this.state.postsRes
: this.state.commentsRes,
]);
}
constructor(props: any, context: any) {
super(props, context);
@ -334,8 +346,6 @@ export class Home extends Component<HomeRouteProps, HomeState> {
) {
await Promise.all([this.fetchTrendingCommunities(), this.fetchData()]);
}
setupTippy();
}
static async fetchInitialData({
@ -667,11 +677,6 @@ export class Home extends Component<HomeRouteProps, HomeState> {
search: getQueryString(queryParams),
});
if (!this.state.scrolled) {
this.setState({ scrolled: true });
setTimeout(() => window.scrollTo(0, 0), 0);
}
await this.fetchData();
}
@ -852,8 +857,6 @@ export class Home extends Component<HomeRouteProps, HomeState> {
}),
});
}
setupTippy();
}
handleShowSubscribedMobile(i: Home) {
@ -876,27 +879,23 @@ export class Home extends Component<HomeRouteProps, HomeState> {
this.props.history.back();
// A hack to scroll to top
setTimeout(() => {
window.scrollTo(0, 0);
snapToTop();
}, 50);
}
handlePageNext(nextPage: PaginationCursor) {
this.setState({ scrolled: false });
this.updateUrl({ pageCursor: nextPage });
}
handleSortChange(val: SortType) {
this.setState({ scrolled: false });
this.updateUrl({ sort: val, pageCursor: undefined });
}
handleListingTypeChange(val: ListingType) {
this.setState({ scrolled: false });
this.updateUrl({ listingType: val, pageCursor: undefined });
}
handleDataTypeChange(val: DataType) {
this.setState({ scrolled: false });
this.updateUrl({ dataType: val, pageCursor: undefined });
}

View file

@ -24,6 +24,8 @@ import Tabs from "../common/tabs";
import { getHttpBaseInternal } from "../../utils/env";
import { RouteComponentProps } from "inferno-router/dist/Route";
import { IRoutePropsWithFetch } from "../../routes";
import { resourcesSettled } from "@utils/helpers";
import { scrollMixin } from "../mixins/scroll-mixin";
type InstancesData = RouteDataResponse<{
federatedInstancesResponse: GetFederatedInstancesResponse;
@ -43,6 +45,7 @@ export type InstancesFetchConfig = IRoutePropsWithFetch<
Record<string, never>
>;
@scrollMixin
export class Instances extends Component<InstancesRouteProps, InstancesState> {
private isoData = setIsoData<InstancesData>(this.context);
state: InstancesState = {
@ -51,6 +54,10 @@ export class Instances extends Component<InstancesRouteProps, InstancesState> {
isIsomorphic: false,
};
loadingSettled() {
return resourcesSettled([this.state.instancesRes]);
}
constructor(props: any, context: any) {
super(props, context);

View file

@ -6,6 +6,8 @@ import { HttpService, I18NextService } from "../../services";
import { toast } from "../../toast";
import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon";
import { simpleScrollMixin } from "../mixins/scroll-mixin";
import { RouteComponentProps } from "inferno-router/dist/Route";
interface State {
form: {
@ -15,7 +17,11 @@ interface State {
siteRes: GetSiteResponse;
}
export class LoginReset extends Component<any, State> {
@simpleScrollMixin
export class LoginReset extends Component<
RouteComponentProps<Record<string, never>>,
State
> {
private isoData = setIsoData(this.context);
state: State = {

View file

@ -19,6 +19,7 @@ import TotpModal from "../common/totp-modal";
import { UnreadCounterService } from "../../services";
import { RouteData } from "../../interfaces";
import { IRoutePropsWithFetch } from "../../routes";
import { simpleScrollMixin } from "../mixins/scroll-mixin";
interface LoginProps {
prev?: string;
@ -125,6 +126,7 @@ export type LoginFetchConfig = IRoutePropsWithFetch<
LoginProps
>;
@simpleScrollMixin
export class Login extends Component<LoginRouteProps, State> {
private isoData = setIsoData(this.context);

View file

@ -17,6 +17,8 @@ import {
import { Spinner } from "../common/icon";
import PasswordInput from "../common/password-input";
import { SiteForm } from "./site-form";
import { simpleScrollMixin } from "../mixins/scroll-mixin";
import { RouteComponentProps } from "inferno-router/dist/Route";
interface State {
form: {
@ -36,7 +38,11 @@ interface State {
siteRes: GetSiteResponse;
}
export class Setup extends Component<any, State> {
@simpleScrollMixin
export class Setup extends Component<
RouteComponentProps<Record<string, never>>,
State
> {
private isoData = setIsoData(this.context);
state: State = {

View file

@ -1,6 +1,7 @@
import { setIsoData } from "@utils/app";
import { isBrowser } from "@utils/browser";
import { validEmail } from "@utils/helpers";
import { resourcesSettled, validEmail } from "@utils/helpers";
import { scrollMixin } from "../mixins/scroll-mixin";
import { Component, linkEvent } from "inferno";
import { T } from "inferno-i18next-dess";
import {
@ -24,6 +25,7 @@ import { HtmlTags } from "../common/html-tags";
import { Icon, Spinner } from "../common/icon";
import { MarkdownTextArea } from "../common/markdown-textarea";
import PasswordInput from "../common/password-input";
import { RouteComponentProps } from "inferno-router/dist/Route";
interface State {
registerRes: RequestState<LoginResponse>;
@ -43,7 +45,11 @@ interface State {
siteRes: GetSiteResponse;
}
export class Signup extends Component<any, State> {
@scrollMixin
export class Signup extends Component<
RouteComponentProps<Record<string, never>>,
State
> {
private isoData = setIsoData(this.context);
private audio?: HTMLAudioElement;
@ -57,6 +63,13 @@ export class Signup extends Component<any, State> {
siteRes: this.isoData.site_res,
};
loadingSettled() {
return (
!this.state.siteRes.site_view.local_site.captcha_enabled ||
resourcesSettled([this.state.captchaRes])
);
}
constructor(props: any, context: any) {
super(props, context);

View file

@ -7,6 +7,7 @@ import { Badges } from "../common/badges";
import { BannerIconHeader } from "../common/banner-icon-header";
import { Icon } from "../common/icon";
import { PersonListing } from "../person/person-listing";
import { tippyMixin } from "../mixins/tippy-mixin";
interface SiteSidebarProps {
site: Site;
@ -20,6 +21,7 @@ interface SiteSidebarState {
collapsed: boolean;
}
@tippyMixin
export class SiteSidebar extends Component<SiteSidebarProps, SiteSidebarState> {
state: SiteSidebarState = {
collapsed: false,

View file

@ -4,6 +4,7 @@ import { EditSite, Tagline } from "lemmy-js-client";
import { I18NextService } from "../../services";
import { Icon, Spinner } from "../common/icon";
import { MarkdownTextArea } from "../common/markdown-textarea";
import { tippyMixin } from "../mixins/tippy-mixin";
interface TaglineFormProps {
taglines: Array<Tagline>;
@ -16,6 +17,7 @@ interface TaglineFormState {
editingRow?: number;
}
@tippyMixin
export class TaglineForm extends Component<TaglineFormProps, TaglineFormState> {
state: TaglineFormState = {
editingRow: undefined,

View file

@ -0,0 +1,145 @@
import { isBrowser, nextUserAction, snapToTop } from "../../utils/browser";
import { Component, InfernoNode } from "inferno";
import { Location } from "history";
function restoreScrollPosition(props: { location: Location }) {
const key: string = props.location.key;
const y = sessionStorage.getItem(`scrollPosition_${key}`);
if (y !== null) {
window.scrollTo({ left: 0, top: Number(y), behavior: "instant" });
}
}
function saveScrollPosition(props: { location: Location }) {
const key: string = props.location.key;
const y = window.scrollY;
sessionStorage.setItem(`scrollPosition_${key}`, y.toString());
}
function dropScrollPosition(props: { location: Location }) {
const key: string = props.location.key;
sessionStorage.removeItem(`scrollPosition_${key}`);
}
export function scrollMixin<
P extends { location: Location },
S,
Base extends new (
...args: any
) => Component<P, S> & { loadingSettled(): boolean },
>(base: Base, _context?: ClassDecoratorContext<Base>) {
return class extends base {
private stopUserListener: (() => void) | undefined;
private blocked?: string;
constructor(...args: any[]) {
super(...args);
if (!isBrowser()) {
return;
}
this.reset();
}
componentDidMount() {
this.restoreIfLoaded();
return super.componentDidMount?.();
}
componentDidUpdate(
prevProps: Readonly<{ children?: InfernoNode } & P>,
prevState: S,
snapshot: any,
) {
this.restoreIfLoaded();
return super.componentDidUpdate?.(prevProps, prevState, snapshot);
}
componentWillUnmount() {
this.saveFinalPosition();
return super.componentWillUnmount?.();
}
componentWillReceiveProps(
nextProps: Readonly<{ children?: InfernoNode } & P>,
nextContext: any,
) {
// Currently this is hypothetical. Components unmount before route changes.
if (this.props.location.key !== nextProps.location.key) {
this.saveFinalPosition();
this.reset();
}
return super.componentWillReceiveProps?.(nextProps, nextContext);
}
unloadListeners = () => {
// Browsers restore the position after reload, but not after pressing
// Enter in the url bar. It's hard to distinguish the two, let the
// browser do its thing.
window.history.scrollRestoration = "auto";
dropScrollPosition(this.props);
};
reset() {
this.blocked = undefined;
this.stopUserListener?.();
// While inferno is rendering no events are dispatched. This only catches
// user interactions when network responses are slow/late.
this.stopUserListener = nextUserAction(() => {
this.preventRestore();
});
window.removeEventListener("beforeunload", this.unloadListeners);
window.addEventListener("beforeunload", this.unloadListeners);
}
savePosition() {
saveScrollPosition(this.props);
}
saveFinalPosition() {
this.savePosition();
snapToTop();
window.removeEventListener("beforeunload", this.unloadListeners);
}
preventRestore() {
this.blocked = this.props.location.key;
this.stopUserListener?.();
this.stopUserListener = undefined;
}
restore() {
restoreScrollPosition(this.props);
this.preventRestore();
}
restoreIfLoaded() {
if (!this.isPending() || !this.loadingSettled()) {
return;
}
this.restore();
}
isPending() {
return this.blocked !== this.props.location.key;
}
};
}
export function simpleScrollMixin<
P extends { location: Location },
S,
Base extends new (...args: any) => Component<P, S>,
>(base: Base, _context?: ClassDecoratorContext<Base>) {
@scrollMixin
class SimpleScrollMixin extends base {
loadingSettled() {
return true;
}
}
return SimpleScrollMixin;
}

View file

@ -0,0 +1,25 @@
import { Component, InfernoNode } from "inferno";
import { cleanupTippy } from "../../tippy";
export function tippyMixin<
P,
S,
Base extends new (...args: any) => Component<P, S>,
>(base: Base, _context?: ClassDecoratorContext<Base>) {
return class extends base {
componentDidUpdate(
prevProps: P & { children?: InfernoNode },
prevState: S,
snapshot: any,
) {
// For conditional rendering, old tippy instances aren't reused
cleanupTippy();
return super.componentDidUpdate?.(prevProps, prevState, snapshot);
}
componentWillUnmount() {
cleanupTippy();
return super.componentWillUnmount?.();
}
};
}

View file

@ -11,7 +11,9 @@ import {
getPageFromString,
getQueryParams,
getQueryString,
resourcesSettled,
} from "@utils/helpers";
import { scrollMixin } from "./mixins/scroll-mixin";
import { amAdmin, amMod } from "@utils/roles";
import type { QueryParams } from "@utils/types";
import { Choice, RouteDataResponse } from "@utils/types";
@ -645,6 +647,7 @@ export type ModlogFetchConfig = IRoutePropsWithFetch<
ModlogProps
>;
@scrollMixin
export class Modlog extends Component<ModlogRouteProps, ModlogState> {
private isoData = setIsoData<ModlogData>(this.context);
@ -658,6 +661,10 @@ export class Modlog extends Component<ModlogRouteProps, ModlogState> {
isIsomorphic: false,
};
loadingSettled() {
return resourcesSettled([this.state.res]);
}
constructor(props: ModlogRouteProps, context: any) {
super(props, context);
this.handlePageChange = this.handlePageChange.bind(this);

View file

@ -1,11 +1,13 @@
import { Component } from "inferno";
import { I18NextService } from "../../services";
import { Icon } from "../common/icon";
import { tippyMixin } from "../mixins/tippy-mixin";
interface CakeDayProps {
creatorName: string;
}
@tippyMixin
export class CakeDay extends Component<CakeDayProps, any> {
render() {
return (

View file

@ -10,7 +10,12 @@ import {
setIsoData,
updatePersonBlock,
} from "@utils/app";
import { capitalizeFirstLetter, randomStr } from "@utils/helpers";
import {
capitalizeFirstLetter,
randomStr,
resourcesSettled,
} from "@utils/helpers";
import { scrollMixin } from "../mixins/scroll-mixin";
import { RouteDataResponse } from "@utils/types";
import classNames from "classnames";
import { Component, linkEvent } from "inferno";
@ -137,6 +142,7 @@ export type InboxFetchConfig = IRoutePropsWithFetch<
Record<string, never>
>;
@scrollMixin
export class Inbox extends Component<InboxRouteProps, InboxState> {
private isoData = setIsoData<InboxData>(this.context);
state: InboxState = {
@ -153,6 +159,14 @@ export class Inbox extends Component<InboxRouteProps, InboxState> {
isIsomorphic: false,
};
loadingSettled() {
return resourcesSettled([
this.state.repliesRes,
this.state.mentionsRes,
this.state.messagesRes,
]);
}
constructor(props: any, context: any) {
super(props, context);

View file

@ -12,6 +12,8 @@ import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon";
import PasswordInput from "../common/password-input";
import { toast } from "../../toast";
import { simpleScrollMixin } from "../mixins/scroll-mixin";
import { RouteComponentProps } from "inferno-router/dist/Route";
interface State {
passwordChangeRes: RequestState<SuccessResponse>;
@ -23,7 +25,11 @@ interface State {
siteRes: GetSiteResponse;
}
export class PasswordChange extends Component<any, State> {
@simpleScrollMixin
export class PasswordChange extends Component<
RouteComponentProps<Record<string, never>>,
State
> {
private isoData = setIsoData(this.context);
state: State = {

View file

@ -41,7 +41,6 @@ import {
TransferCommunity,
} from "lemmy-js-client";
import { CommentViewType, PersonDetailsView } from "../../interfaces";
import { setupTippy } from "../../tippy";
import { CommentNodes } from "../comment/comment-nodes";
import { Paginator } from "../common/paginator";
import { PostListing } from "../post/post-listing";
@ -109,10 +108,6 @@ export class PersonDetails extends Component<PersonDetailsProps, any> {
this.handlePageChange = this.handlePageChange.bind(this);
}
componentDidMount() {
setupTippy();
}
render() {
return (
<div className="person-details">

View file

@ -8,7 +8,7 @@ import {
setIsoData,
updatePersonBlock,
} from "@utils/app";
import { restoreScrollPosition, saveScrollPosition } from "@utils/browser";
import { scrollMixin } from "../mixins/scroll-mixin";
import {
capitalizeFirstLetter,
futureDaysToUnixTime,
@ -17,6 +17,7 @@ import {
getQueryString,
numToSI,
randomStr,
resourcesSettled,
} from "@utils/helpers";
import { canMod, isBanned } from "@utils/roles";
import type { QueryParams } from "@utils/types";
@ -82,7 +83,6 @@ import {
RequestState,
wrapClient,
} from "../../services/HttpService";
import { setupTippy } from "../../tippy";
import { toast } from "../../toast";
import { BannerIconHeader } from "../common/banner-icon-header";
import { HtmlTags } from "../common/html-tags";
@ -183,6 +183,7 @@ export type ProfileFetchConfig = IRoutePropsWithFetch<
ProfileProps
>;
@scrollMixin
export class Profile extends Component<ProfileRouteProps, ProfileState> {
private isoData = setIsoData<ProfileData>(this.context);
state: ProfileState = {
@ -195,6 +196,10 @@ export class Profile extends Component<ProfileRouteProps, ProfileState> {
isIsomorphic: false,
};
loadingSettled() {
return resourcesSettled([this.state.personRes]);
}
constructor(props: ProfileRouteProps, context: any) {
super(props, context);
@ -249,11 +254,6 @@ export class Profile extends Component<ProfileRouteProps, ProfileState> {
if (!this.state.isIsomorphic) {
await this.fetchUserData();
}
setupTippy();
}
componentWillUnmount() {
saveScrollPosition(this.context);
}
async fetchUserData() {
@ -271,7 +271,6 @@ export class Profile extends Component<ProfileRouteProps, ProfileState> {
personRes,
personBlocked: isPersonBlocked(personRes),
});
restoreScrollPosition(this.context);
}
get amCurrentUser() {

View file

@ -1,5 +1,6 @@
import { editRegistrationApplication, setIsoData } from "@utils/app";
import { randomStr } from "@utils/helpers";
import { randomStr, resourcesSettled } from "@utils/helpers";
import { scrollMixin } from "../mixins/scroll-mixin";
import { RouteDataResponse } from "@utils/types";
import classNames from "classnames";
import { Component, linkEvent } from "inferno";
@ -20,7 +21,6 @@ import {
RequestState,
wrapClient,
} from "../../services/HttpService";
import { setupTippy } from "../../tippy";
import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon";
import { Paginator } from "../common/paginator";
@ -58,6 +58,7 @@ export type RegistrationApplicationsFetchConfig = IRoutePropsWithFetch<
Record<string, never>
>;
@scrollMixin
export class RegistrationApplications extends Component<
RegistrationApplicationsRouteProps,
RegistrationApplicationsState
@ -71,6 +72,10 @@ export class RegistrationApplications extends Component<
isIsomorphic: false,
};
loadingSettled() {
return resourcesSettled([this.state.appsRes]);
}
constructor(props: any, context: any) {
super(props, context);
@ -91,7 +96,6 @@ export class RegistrationApplications extends Component<
if (!this.state.isIsomorphic) {
await this.refetch();
}
setupTippy();
}
get documentTitle(): string {

View file

@ -4,7 +4,8 @@ import {
editPrivateMessageReport,
setIsoData,
} from "@utils/app";
import { randomStr } from "@utils/helpers";
import { randomStr, resourcesSettled } from "@utils/helpers";
import { scrollMixin } from "../mixins/scroll-mixin";
import { amAdmin } from "@utils/roles";
import { RouteDataResponse } from "@utils/types";
import classNames from "classnames";
@ -103,6 +104,7 @@ export type ReportsFetchConfig = IRoutePropsWithFetch<
Record<string, never>
>;
@scrollMixin
export class Reports extends Component<ReportsRouteProps, ReportsState> {
private isoData = setIsoData<ReportsData>(this.context);
state: ReportsState = {
@ -116,6 +118,14 @@ export class Reports extends Component<ReportsRouteProps, ReportsState> {
isIsomorphic: false,
};
loadingSettled() {
return resourcesSettled([
this.state.commentReportsRes,
this.state.postReportsRes,
this.state.messageReportsRes,
]);
}
constructor(props: any, context: any) {
super(props, context);

View file

@ -49,7 +49,7 @@ import {
languages,
loadUserLanguage,
} from "../../services/I18NextService";
import { setupTippy } from "../../tippy";
import { tippyMixin } from "../mixins/tippy-mixin";
import { toast } from "../../toast";
import { HtmlTags } from "../common/html-tags";
import { Icon, Spinner } from "../common/icon";
@ -66,10 +66,11 @@ import { PersonListing } from "./person-listing";
import { InitialFetchRequest } from "../../interfaces";
import TotpModal from "../common/totp-modal";
import { LoadingEllipses } from "../common/loading-ellipses";
import { refreshTheme, setThemeOverride } from "../../utils/browser";
import { refreshTheme, setThemeOverride, snapToTop } from "../../utils/browser";
import { getHttpBaseInternal } from "../../utils/env";
import { IRoutePropsWithFetch } from "../../routes";
import { RouteComponentProps } from "inferno-router/dist/Route";
import { simpleScrollMixin } from "../mixins/scroll-mixin";
type SettingsData = RouteDataResponse<{
instancesRes: GetFederatedInstancesResponse;
@ -203,6 +204,8 @@ export type SettingsFetchConfig = IRoutePropsWithFetch<
Record<string, never>
>;
@simpleScrollMixin
@tippyMixin
export class Settings extends Component<SettingsRouteProps, SettingsState> {
private isoData = setIsoData<SettingsData>(this.context);
exportSettingsLink = createRef<HTMLAnchorElement>();
@ -334,7 +337,6 @@ export class Settings extends Component<SettingsRouteProps, SettingsState> {
}
async componentDidMount() {
setupTippy();
this.setState({ themeList: await fetchThemeList() });
if (!this.state.isIsomorphic) {
@ -1578,7 +1580,7 @@ export class Settings extends Component<SettingsRouteProps, SettingsState> {
}
toast(I18NextService.i18n.t("saved"));
window.scrollTo(0, 0);
snapToTop();
}
setThemeOverride(undefined);
@ -1598,7 +1600,7 @@ export class Settings extends Component<SettingsRouteProps, SettingsState> {
old_password,
});
if (changePasswordRes.state === "success") {
window.scrollTo(0, 0);
snapToTop();
toast(I18NextService.i18n.t("password_changed"));
}

View file

@ -11,13 +11,19 @@ import {
import { toast } from "../../toast";
import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon";
import { simpleScrollMixin } from "../mixins/scroll-mixin";
import { RouteComponentProps } from "inferno-router/dist/Route";
interface State {
verifyRes: RequestState<SuccessResponse>;
siteRes: GetSiteResponse;
}
export class VerifyEmail extends Component<any, State> {
@simpleScrollMixin
export class VerifyEmail extends Component<
RouteComponentProps<Record<string, never>>,
State
> {
private isoData = setIsoData(this.context);
state: State = {

View file

@ -30,6 +30,7 @@ import { Spinner } from "../common/icon";
import { PostForm } from "./post-form";
import { getHttpBaseInternal } from "../../utils/env";
import { IRoutePropsWithFetch } from "../../routes";
import { simpleScrollMixin } from "../mixins/scroll-mixin";
export interface CreatePostProps {
communityId?: number;
@ -70,6 +71,7 @@ export type CreatePostFetchConfig = IRoutePropsWithFetch<
CreatePostProps
>;
@simpleScrollMixin
export class CreatePost extends Component<
CreatePostRouteProps,
CreatePostState

View file

@ -37,7 +37,6 @@ import {
LOADING_REQUEST,
RequestState,
} from "../../services/HttpService";
import { setupTippy } from "../../tippy";
import { toast } from "../../toast";
import { Icon, Spinner } from "../common/icon";
import { LanguageSelect } from "../common/language-select";
@ -306,7 +305,6 @@ export class PostForm extends Component<PostFormProps, PostFormState> {
}
componentDidMount() {
setupTippy();
const textarea: any = document.getElementById("post-title");
if (textarea) {

View file

@ -35,7 +35,7 @@ import { relTags } from "../../config";
import { VoteContentType } from "../../interfaces";
import { mdToHtml, mdToHtmlInline } from "../../markdown";
import { I18NextService, UserService } from "../../services";
import { setupTippy } from "../../tippy";
import { tippyMixin } from "../mixins/tippy-mixin";
import { Icon } from "../common/icon";
import { MomentTime } from "../common/moment-time";
import { PictrsImage } from "../common/pictrs-image";
@ -91,8 +91,10 @@ interface PostListingProps {
onAddAdmin(form: AddAdmin): Promise<void>;
onTransferCommunity(form: TransferCommunity): Promise<void>;
onMarkPostAsRead(form: MarkPostAsRead): void;
onScrollIntoCommentsClick?(e: MouseEvent): void;
}
@tippyMixin
export class PostListing extends Component<PostListingProps, PostListingState> {
state: PostListingState = {
showEdit: false,
@ -636,6 +638,7 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
title={title}
to={`/post/${pv.post.id}?scrollToComments=true`}
data-tippy-content={title}
onClick={this.props.onScrollIntoCommentsClick}
>
<Icon icon="message-square" classes="me-1" inline />
{pv.counts.comments}
@ -982,7 +985,6 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
handleImageExpandClick(i: PostListing, event: any) {
event.preventDefault();
i.setState({ imageExpanded: !i.state.imageExpanded });
setupTippy();
if (myAuth() && !i.postView.read) {
i.props.onMarkPostAsRead({
@ -998,7 +1000,6 @@ export class PostListing extends Component<PostListingProps, PostListingState> {
handleShowBody(i: PostListing) {
i.setState({ showBody: !i.state.showBody });
setupTippy();
}
get pointsTippy(): string {

View file

@ -6,6 +6,7 @@ import { Icon, Spinner } from "../common/icon";
import { PersonListing } from "../person/person-listing";
import { PostListing } from "./post-listing";
import { EMPTY_REQUEST } from "../../services/HttpService";
import { tippyMixin } from "../mixins/tippy-mixin";
interface PostReportProps {
report: PostReportView;
@ -16,6 +17,7 @@ interface PostReportState {
loading: boolean;
}
@tippyMixin
export class PostReport extends Component<PostReportProps, PostReportState> {
state: PostReportState = {
loading: false,

View file

@ -13,12 +13,14 @@ import {
updateCommunityBlock,
updatePersonBlock,
} from "@utils/app";
import { isBrowser } from "@utils/browser";
import {
isBrowser,
restoreScrollPosition,
saveScrollPosition,
} from "@utils/browser";
import { debounce, getApubName, randomStr } from "@utils/helpers";
debounce,
getApubName,
randomStr,
resourcesSettled,
} from "@utils/helpers";
import { scrollMixin } from "../mixins/scroll-mixin";
import { isImage } from "@utils/media";
import { RouteDataResponse } from "@utils/types";
import autosize from "autosize";
@ -89,7 +91,6 @@ import {
RequestState,
wrapClient,
} from "../../services/HttpService";
import { setupTippy } from "../../tippy";
import { toast } from "../../toast";
import { CommentForm } from "../comment/comment-form";
import { CommentNodes } from "../comment/comment-nodes";
@ -135,6 +136,7 @@ export type PostFetchConfig = IRoutePropsWithFetch<
Record<string, never>
>;
@scrollMixin
export class Post extends Component<PostRouteProps, PostState> {
private isoData = setIsoData<PostData>(this.context);
private commentScrollDebounced: () => void;
@ -153,6 +155,10 @@ export class Post extends Component<PostRouteProps, PostState> {
isIsomorphic: false,
};
loadingSettled() {
return resourcesSettled([this.state.postRes, this.state.commentsRes]);
}
constructor(props: any, context: any) {
super(props, context);
@ -189,6 +195,8 @@ export class Post extends Component<PostRouteProps, PostState> {
this.handleSavePost = this.handleSavePost.bind(this);
this.handlePurgePost = this.handlePurgePost.bind(this);
this.handleFeaturePost = this.handleFeaturePost.bind(this);
this.handleScrollIntoCommentsClick =
this.handleScrollIntoCommentsClick.bind(this);
this.state = { ...this.state, commentSectionRef: createRef() };
@ -237,10 +245,6 @@ export class Post extends Component<PostRouteProps, PostState> {
commentsRes,
});
setupTippy();
if (!this.state.commentId) restoreScrollPosition(this.context);
if (this.checkScrollIntoCommentsParam) {
this.scrollIntoCommentSection();
}
@ -284,8 +288,6 @@ export class Post extends Component<PostRouteProps, PostState> {
componentWillUnmount() {
document.removeEventListener("scroll", this.commentScrollDebounced);
saveScrollPosition(this.context);
}
async componentDidMount() {
@ -299,16 +301,16 @@ export class Post extends Component<PostRouteProps, PostState> {
document.addEventListener("scroll", this.commentScrollDebounced);
}
async componentDidUpdate(_lastProps: any) {
// Necessary if you are on a post and you click another post (same route)
if (_lastProps.location.pathname !== _lastProps.history.location.pathname) {
await this.fetchPost();
}
handleScrollIntoCommentsClick(e: MouseEvent) {
this.scrollIntoCommentSection();
e.preventDefault();
}
get checkScrollIntoCommentsParam() {
return Boolean(
return (
Boolean(
new URLSearchParams(this.props.location.search).get("scrollToComments"),
) && this.props.history.action !== "POP"
);
}
@ -403,6 +405,7 @@ export class Post extends Component<PostRouteProps, PostState> {
onTransferCommunity={this.handleTransferCommunity}
onFeaturePost={this.handleFeaturePost}
onMarkPostAsRead={() => {}}
onScrollIntoCommentsClick={this.handleScrollIntoCommentsClick}
/>
<div ref={this.state.commentSectionRef} className="mb-2" />

View file

@ -24,6 +24,8 @@ import { PrivateMessageForm } from "./private-message-form";
import { getHttpBaseInternal } from "../../utils/env";
import { RouteComponentProps } from "inferno-router/dist/Route";
import { IRoutePropsWithFetch } from "../../routes";
import { resourcesSettled } from "@utils/helpers";
import { scrollMixin } from "../mixins/scroll-mixin";
type CreatePrivateMessageData = RouteDataResponse<{
recipientDetailsResponse: GetPersonDetailsResponse;
@ -45,6 +47,7 @@ export type CreatePrivateMessageFetchConfig = IRoutePropsWithFetch<
Record<string, never>
>;
@scrollMixin
export class CreatePrivateMessage extends Component<
CreatePrivateMessageRouteProps,
CreatePrivateMessageState
@ -57,6 +60,10 @@ export class CreatePrivateMessage extends Component<
isIsomorphic: false,
};
loadingSettled() {
return resourcesSettled([this.state.recipientRes]);
}
constructor(props: any, context: any) {
super(props, context);
this.handlePrivateMessageCreate =

View file

@ -10,7 +10,6 @@ import {
} from "lemmy-js-client";
import { relTags } from "../../config";
import { I18NextService } from "../../services";
import { setupTippy } from "../../tippy";
import { Icon } from "../common/icon";
import { MarkdownTextArea } from "../common/markdown-textarea";
import { PersonListing } from "../person/person-listing";
@ -50,10 +49,6 @@ export class PrivateMessageForm extends Component<
this.handleContentChange = this.handleContentChange.bind(this);
}
componentDidMount() {
setupTippy();
}
componentWillReceiveProps(
nextProps: Readonly<{ children?: InfernoNode } & PrivateMessageFormProps>,
): void {

View file

@ -8,6 +8,7 @@ import { mdToHtml } from "../../markdown";
import { I18NextService } from "../../services";
import { Icon, Spinner } from "../common/icon";
import { PersonListing } from "../person/person-listing";
import { tippyMixin } from "../mixins/tippy-mixin";
interface Props {
report: PrivateMessageReportView;
@ -18,6 +19,7 @@ interface State {
loading: boolean;
}
@tippyMixin
export class PrivateMessageReport extends Component<Props, State> {
state: State = {
loading: false,

View file

@ -15,6 +15,7 @@ import { MomentTime } from "../common/moment-time";
import { PersonListing } from "../person/person-listing";
import { PrivateMessageForm } from "./private-message-form";
import ModActionFormModal from "../common/mod-action-form-modal";
import { tippyMixin } from "../mixins/tippy-mixin";
interface PrivateMessageState {
showReply: boolean;
@ -35,6 +36,7 @@ interface PrivateMessageProps {
onEdit(form: EditPrivateMessage): void;
}
@tippyMixin
export class PrivateMessage extends Component<
PrivateMessageProps,
PrivateMessageState

View file

@ -1,5 +1,6 @@
import { setIsoData } from "@utils/app";
import { getQueryParams } from "@utils/helpers";
import { getQueryParams, resourcesSettled } from "@utils/helpers";
import { scrollMixin } from "./mixins/scroll-mixin";
import { RouteDataResponse } from "@utils/types";
import { Component, linkEvent } from "inferno";
import {
@ -97,6 +98,7 @@ export type RemoteFetchFetchConfig = IRoutePropsWithFetch<
RemoteFetchProps
>;
@scrollMixin
export class RemoteFetch extends Component<
RemoteFetchRouteProps,
RemoteFetchState
@ -108,6 +110,10 @@ export class RemoteFetch extends Component<
followCommunityLoading: false,
};
loadingSettled() {
return resourcesSettled([this.state.resolveObjectRes]);
}
constructor(props: RemoteFetchRouteProps, context: any) {
super(props, context);

View file

@ -11,7 +11,7 @@ import {
setIsoData,
showLocal,
} from "@utils/app";
import { restoreScrollPosition, saveScrollPosition } from "@utils/browser";
import { scrollMixin } from "./mixins/scroll-mixin";
import {
capitalizeFirstLetter,
debounce,
@ -21,6 +21,7 @@ import {
getQueryParams,
getQueryString,
numToSI,
resourcesSettled,
} from "@utils/helpers";
import type { QueryParams } from "@utils/types";
import { Choice, RouteDataResponse } from "@utils/types";
@ -253,6 +254,7 @@ export type SearchFetchConfig = IRoutePropsWithFetch<
SearchProps
>;
@scrollMixin
export class Search extends Component<SearchRouteProps, SearchState> {
private isoData = setIsoData<SearchData>(this.context);
searchInput = createRef<HTMLInputElement>();
@ -268,6 +270,10 @@ export class Search extends Component<SearchRouteProps, SearchState> {
isIsomorphic: false,
};
loadingSettled() {
return resourcesSettled([this.state.searchRes]);
}
constructor(props: SearchRouteProps, context: any) {
super(props, context);
@ -323,7 +329,9 @@ export class Search extends Component<SearchRouteProps, SearchState> {
}
async componentDidMount() {
if (this.props.history.action !== "POP") {
this.searchInput.current?.select();
}
if (!this.state.isIsomorphic) {
this.setState({
@ -397,10 +405,6 @@ export class Search extends Component<SearchRouteProps, SearchState> {
}
}
componentWillUnmount() {
saveScrollPosition(this.context);
}
static async fetchInitialData({
headers,
query: {
@ -991,8 +995,6 @@ export class Search extends Component<SearchRouteProps, SearchState> {
limit: fetchLimit,
}),
});
window.scrollTo(0, 0);
restoreScrollPosition(this.context);
if (myAuth()) {
this.setState({ resolveObjectRes: LOADING_REQUEST });

View file

@ -1,19 +1,55 @@
import { isBrowser } from "@utils/browser";
import tippy from "tippy.js";
import { RefObject } from "inferno";
import {
DelegateInstance as TippyDelegateInstance,
Props as TippyProps,
Instance as TippyInstance,
delegate as tippyDelegate,
} from "tippy.js";
export let tippyInstance: any;
let instance: TippyDelegateInstance<TippyProps> | undefined;
const tippySelector = "[data-tippy-content]";
const shownInstances: Set<TippyInstance<TippyProps>> = new Set();
if (isBrowser()) {
tippyInstance = tippy("[data-tippy-content]");
}
export function setupTippy() {
if (isBrowser()) {
tippyInstance.forEach((e: any) => e.destroy());
tippyInstance = tippy("[data-tippy-content]", {
const tippyDelegateOptions: Partial<TippyProps> & { target: string } = {
delay: [500, 0],
// Display on "long press"
touch: ["hold", 500],
target: tippySelector,
onShow(i: TippyInstance<TippyProps>) {
shownInstances.add(i);
},
onHidden(i: TippyInstance<TippyProps>) {
shownInstances.delete(i);
},
};
export function setupTippy(root: RefObject<Element>) {
if (!instance && root.current) {
instance = tippyDelegate(root.current, tippyDelegateOptions);
}
}
let requested = false;
export function cleanupTippy() {
if (requested) {
return;
}
requested = true;
queueMicrotask(() => {
requested = false;
if (shownInstances.size) {
// Avoid randomly closing tooltips.
return;
}
// delegate from tippy.js creates tippy instances when needed, but only
// destroys them when the delegate instance is destroyed.
const current = instance?.reference ?? null;
destroyTippy();
setupTippy({ current });
});
}
export function destroyTippy() {
instance?.destroy();
instance = undefined;
}

View file

@ -3,13 +3,13 @@ import clearAuthCookie from "./clear-auth-cookie";
import dataBsTheme from "./data-bs-theme";
import isBrowser from "./is-browser";
import isDark from "./is-dark";
import nextUserAction from "./next-user-action";
import platform from "./platform";
import refreshTheme from "./refresh-theme";
import restoreScrollPosition from "./restore-scroll-position";
import saveScrollPosition from "./save-scroll-position";
import setAuthCookie from "./set-auth-cookie";
import setThemeOverride from "./set-theme-override";
import share from "./share";
import snapToTop from "./snap-to-top";
export {
canShare,
@ -17,11 +17,11 @@ export {
dataBsTheme,
isBrowser,
isDark,
nextUserAction,
platform,
refreshTheme,
restoreScrollPosition,
saveScrollPosition,
setAuthCookie,
setThemeOverride,
share,
snapToTop,
};

View file

@ -0,0 +1,46 @@
const eventTypes = ["mousedown", "keydown", "touchstart", "touchmove", "wheel"];
const scrollThreshold = 2;
type Continue = boolean | void;
export default function nextUserAction(cb: (e: Event) => Continue) {
const eventTarget = window.document.body;
let cleanup: (() => void) | undefined = () => {
cleanup = undefined;
eventTypes.forEach(ev => {
eventTarget.removeEventListener(ev, listener);
});
window.removeEventListener("scroll", scrollListener);
};
const listener = (e: Event) => {
if (!cb(e)) {
cleanup?.();
}
};
eventTypes.forEach(ev => {
eventTarget.addEventListener(ev, listener);
});
let remaining = scrollThreshold;
const scrollListener = (e: Event) => {
// This only has to cover the scrollbars. The problem is that scroll events
// are also fired when the document height shrinks below the current bottom
// edge of the window.
remaining--;
if (remaining < 0) {
if (!cb(e)) {
cleanup?.();
} else {
remaining = scrollThreshold;
}
}
};
window.addEventListener("scroll", scrollListener);
return () => {
cleanup?.();
};
}

View file

@ -1,6 +0,0 @@
export default function restoreScrollPosition(context: any) {
const path: string = context.router.route.location.pathname;
const y = Number(sessionStorage.getItem(`scrollPosition_${path}`));
window.scrollTo(0, y);
}

View file

@ -1,6 +0,0 @@
export default function saveScrollPosition(context: any) {
const path: string = context.router.route.location.pathname;
const y = window.scrollY;
sessionStorage.setItem(`scrollPosition_${path}`, y.toString());
}

View file

@ -0,0 +1,3 @@
export default function snapToTop() {
window.scrollTo({ left: 0, top: 0, behavior: "instant" });
}

View file

@ -17,6 +17,7 @@ import isCakeDay from "./is-cake-day";
import numToSI from "./num-to-si";
import poll from "./poll";
import randomStr from "./random-str";
import resourcesSettled from "./resources-settled";
import sleep from "./sleep";
import validEmail from "./valid-email";
import validInstanceTLD from "./valid-instance-tld";
@ -45,6 +46,7 @@ export {
numToSI,
poll,
randomStr,
resourcesSettled,
sleep,
validEmail,
validInstanceTLD,

View file

@ -0,0 +1,5 @@
import { RequestState } from "../../services/HttpService";
export default function resourcesSettled(resources: RequestState<any>[]) {
return resources.every(r => r.state === "success" || r.state === "failed");
}

View file

@ -16,7 +16,7 @@
"skipLibCheck": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"experimentalDecorators": true,
"experimentalDecorators": true, // false for non-legacy decorators
"strictNullChecks": true,
"noFallthroughCasesInSwitch": true,
"paths": {