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 }] ["@babel/typescript", { "isTSX": true, "allExtensions": true }]
], ],
"plugins": [ "plugins": [
["@babel/plugin-proposal-decorators", { "version": "legacy" }],
[
"@babel/plugin-transform-runtime", "@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-inferno", { "imports": true }],
["@babel/plugin-transform-class-properties", { "loose": true }] ["@babel/plugin-transform-class-properties", { "loose": true }]
] ]

View file

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

View file

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

View file

@ -13,15 +13,25 @@ import { Navbar } from "./navbar";
import "./styles.scss"; import "./styles.scss";
import { Theme } from "./theme"; import { Theme } from "./theme";
import AnonymousGuard from "../common/anonymous-guard"; import AnonymousGuard from "../common/anonymous-guard";
import { destroyTippy, setupTippy } from "../../tippy";
export class App extends Component<any, any> { export class App extends Component<any, any> {
private isoData: IsoDataOptionalSite = setIsoData(this.context); private isoData: IsoDataOptionalSite = setIsoData(this.context);
private readonly mainContentRef: RefObject<HTMLElement>; private readonly mainContentRef: RefObject<HTMLElement>;
private readonly rootRef = createRef<HTMLDivElement>();
constructor(props: any, context: any) { constructor(props: any, context: any) {
super(props, context); super(props, context);
this.mainContentRef = createRef(); this.mainContentRef = createRef();
} }
componentDidMount(): void {
setupTippy(this.rootRef);
}
componentWillUnmount(): void {
destroyTippy();
}
handleJumpToContent(event) { handleJumpToContent(event) {
event.preventDefault(); event.preventDefault();
this.mainContentRef.current?.focus(); this.mainContentRef.current?.focus();
@ -34,7 +44,7 @@ export class App extends Component<any, any> {
return ( return (
<> <>
<Provider i18next={I18NextService.i18n}> <Provider i18next={I18NextService.i18n}>
<div id="app" className="lemmy-site"> <div id="app" className="lemmy-site" ref={this.rootRef}>
<button <button
type="button" type="button"
className="btn skip-link bg-light position-absolute start-0 z-3" 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 { Icon } from "../common/icon";
import { PictrsImage } from "../common/pictrs-image"; import { PictrsImage } from "../common/pictrs-image";
import { Subscription } from "rxjs"; import { Subscription } from "rxjs";
import { tippyMixin } from "../mixins/tippy-mixin";
interface NavbarProps { interface NavbarProps {
siteRes?: GetSiteResponse; siteRes?: GetSiteResponse;
@ -42,6 +43,7 @@ function handleLogOut(i: Navbar) {
handleCollapseClick(i); handleCollapseClick(i);
} }
@tippyMixin
export class Navbar extends Component<NavbarProps, NavbarState> { export class Navbar extends Component<NavbarProps, NavbarState> {
collapseButtonRef = createRef<HTMLButtonElement>(); collapseButtonRef = createRef<HTMLButtonElement>();
mobileMenuRef = createRef<HTMLDivElement>(); mobileMenuRef = createRef<HTMLDivElement>();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -13,6 +13,7 @@ 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";
interface EmojiFormProps { interface EmojiFormProps {
onEdit(form: EditCustomEmoji): void; onEdit(form: EditCustomEmoji): void;
@ -38,6 +39,7 @@ interface CustomEmojiViewForm {
loading: boolean; loading: boolean;
} }
@tippyMixin
export class EmojiForm extends Component<EmojiFormProps, EmojiFormState> { export class EmojiForm extends Component<EmojiFormProps, EmojiFormState> {
private isoData = setIsoData(this.context); private isoData = setIsoData(this.context);
private itemsPerPage = 15; private itemsPerPage = 15;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,7 @@
import { setIsoData } from "@utils/app"; import { setIsoData } from "@utils/app";
import { isBrowser } from "@utils/browser"; 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 { Component, linkEvent } from "inferno";
import { T } from "inferno-i18next-dess"; import { T } from "inferno-i18next-dess";
import { import {
@ -24,6 +25,7 @@ import { HtmlTags } from "../common/html-tags";
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 PasswordInput from "../common/password-input"; import PasswordInput from "../common/password-input";
import { RouteComponentProps } from "inferno-router/dist/Route";
interface State { interface State {
registerRes: RequestState<LoginResponse>; registerRes: RequestState<LoginResponse>;
@ -43,7 +45,11 @@ interface State {
siteRes: GetSiteResponse; 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 isoData = setIsoData(this.context);
private audio?: HTMLAudioElement; private audio?: HTMLAudioElement;
@ -57,6 +63,13 @@ export class Signup extends Component<any, State> {
siteRes: this.isoData.site_res, 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) { constructor(props: any, context: any) {
super(props, context); super(props, context);

View file

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

View file

@ -4,6 +4,7 @@ import { EditSite, Tagline } from "lemmy-js-client";
import { I18NextService } from "../../services"; import { 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";
interface TaglineFormProps { interface TaglineFormProps {
taglines: Array<Tagline>; taglines: Array<Tagline>;
@ -16,6 +17,7 @@ interface TaglineFormState {
editingRow?: number; editingRow?: number;
} }
@tippyMixin
export class TaglineForm extends Component<TaglineFormProps, TaglineFormState> { export class TaglineForm extends Component<TaglineFormProps, TaglineFormState> {
state: TaglineFormState = { state: TaglineFormState = {
editingRow: undefined, 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, getPageFromString,
getQueryParams, getQueryParams,
getQueryString, getQueryString,
resourcesSettled,
} from "@utils/helpers"; } from "@utils/helpers";
import { scrollMixin } from "./mixins/scroll-mixin";
import { amAdmin, amMod } from "@utils/roles"; import { amAdmin, amMod } from "@utils/roles";
import type { QueryParams } from "@utils/types"; import type { QueryParams } from "@utils/types";
import { Choice, RouteDataResponse } from "@utils/types"; import { Choice, RouteDataResponse } from "@utils/types";
@ -645,6 +647,7 @@ export type ModlogFetchConfig = IRoutePropsWithFetch<
ModlogProps ModlogProps
>; >;
@scrollMixin
export class Modlog extends Component<ModlogRouteProps, ModlogState> { export class Modlog extends Component<ModlogRouteProps, ModlogState> {
private isoData = setIsoData<ModlogData>(this.context); private isoData = setIsoData<ModlogData>(this.context);
@ -658,6 +661,10 @@ export class Modlog extends Component<ModlogRouteProps, ModlogState> {
isIsomorphic: false, isIsomorphic: false,
}; };
loadingSettled() {
return resourcesSettled([this.state.res]);
}
constructor(props: ModlogRouteProps, context: any) { constructor(props: ModlogRouteProps, context: any) {
super(props, context); super(props, context);
this.handlePageChange = this.handlePageChange.bind(this); this.handlePageChange = this.handlePageChange.bind(this);

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,13 +11,19 @@ import {
import { toast } from "../../toast"; import { toast } from "../../toast";
import { HtmlTags } from "../common/html-tags"; import { HtmlTags } from "../common/html-tags";
import { Spinner } from "../common/icon"; import { Spinner } from "../common/icon";
import { simpleScrollMixin } from "../mixins/scroll-mixin";
import { RouteComponentProps } from "inferno-router/dist/Route";
interface State { interface State {
verifyRes: RequestState<SuccessResponse>; verifyRes: RequestState<SuccessResponse>;
siteRes: GetSiteResponse; 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); private isoData = setIsoData(this.context);
state: State = { state: State = {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,19 +1,55 @@
import { isBrowser } from "@utils/browser"; import { RefObject } from "inferno";
import tippy from "tippy.js"; 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()) { const tippyDelegateOptions: Partial<TippyProps> & { target: string } = {
tippyInstance = tippy("[data-tippy-content]");
}
export function setupTippy() {
if (isBrowser()) {
tippyInstance.forEach((e: any) => e.destroy());
tippyInstance = tippy("[data-tippy-content]", {
delay: [500, 0], delay: [500, 0],
// Display on "long press" // Display on "long press"
touch: ["hold", 500], 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 dataBsTheme from "./data-bs-theme";
import isBrowser from "./is-browser"; import isBrowser from "./is-browser";
import isDark from "./is-dark"; import isDark from "./is-dark";
import nextUserAction from "./next-user-action";
import platform from "./platform"; import platform from "./platform";
import refreshTheme from "./refresh-theme"; import refreshTheme from "./refresh-theme";
import restoreScrollPosition from "./restore-scroll-position";
import saveScrollPosition from "./save-scroll-position";
import setAuthCookie from "./set-auth-cookie"; import setAuthCookie from "./set-auth-cookie";
import setThemeOverride from "./set-theme-override"; import setThemeOverride from "./set-theme-override";
import share from "./share"; import share from "./share";
import snapToTop from "./snap-to-top";
export { export {
canShare, canShare,
@ -17,11 +17,11 @@ export {
dataBsTheme, dataBsTheme,
isBrowser, isBrowser,
isDark, isDark,
nextUserAction,
platform, platform,
refreshTheme, refreshTheme,
restoreScrollPosition,
saveScrollPosition,
setAuthCookie, setAuthCookie,
setThemeOverride, setThemeOverride,
share, 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 numToSI from "./num-to-si";
import poll from "./poll"; import poll from "./poll";
import randomStr from "./random-str"; import randomStr from "./random-str";
import resourcesSettled from "./resources-settled";
import sleep from "./sleep"; import sleep from "./sleep";
import validEmail from "./valid-email"; import validEmail from "./valid-email";
import validInstanceTLD from "./valid-instance-tld"; import validInstanceTLD from "./valid-instance-tld";
@ -45,6 +46,7 @@ export {
numToSI, numToSI,
poll, poll,
randomStr, randomStr,
resourcesSettled,
sleep, sleep,
validEmail, validEmail,
validInstanceTLD, 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, "skipLibCheck": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noImplicitReturns": true, "noImplicitReturns": true,
"experimentalDecorators": true, "experimentalDecorators": true, // false for non-legacy decorators
"strictNullChecks": true, "strictNullChecks": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"paths": { "paths": {