diff options
author | crupest <crupest@outlook.com> | 2021-01-13 00:08:23 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-01-13 00:08:23 +0800 |
commit | cf14e89a51919e053ba89b0c78ee71940b18e40a (patch) | |
tree | ec790b67e00f664815a785786083abdb108c9c99 | |
parent | 43cf429cb81e928a6466522864682bd4a68ea42e (diff) | |
parent | e6dff0d19d524d14a3adff7803d9a56264e85f2e (diff) | |
download | timeline-cf14e89a51919e053ba89b0c78ee71940b18e40a.tar.gz timeline-cf14e89a51919e053ba89b0c78ee71940b18e40a.tar.bz2 timeline-cf14e89a51919e053ba89b0c78ee71940b18e40a.zip |
Merge pull request #208 from crupest/front-dev
Front end development.
-rw-r--r-- | FrontEnd/src/app/services/DataHub2.ts | 60 | ||||
-rw-r--r-- | FrontEnd/src/app/services/timeline.ts | 80 | ||||
-rw-r--r-- | FrontEnd/src/app/services/user.ts | 14 | ||||
-rw-r--r-- | FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx | 132 | ||||
-rw-r--r-- | FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx | 86 | ||||
-rw-r--r-- | FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx | 23 | ||||
-rw-r--r-- | FrontEnd/src/app/views/user/index.tsx | 27 |
7 files changed, 180 insertions, 242 deletions
diff --git a/FrontEnd/src/app/services/DataHub2.ts b/FrontEnd/src/app/services/DataHub2.ts index 50ae919b..f0fb724b 100644 --- a/FrontEnd/src/app/services/DataHub2.ts +++ b/FrontEnd/src/app/services/DataHub2.ts @@ -32,6 +32,8 @@ export class DataLine2<TData> { private _current: DataAndStatus<TData> | null = null; private _observers: Subscriber<DataAndStatus<TData>>[] = []; + private _syncPromise: Promise<void> | null = null; + get currentData(): DataAndStatus<TData> | null { return this._current; } @@ -50,7 +52,7 @@ export class DataLine2<TData> { } subscribe(subsriber: Subscriber<DataAndStatus<TData>>): void { - this.sync(); // TODO: Should I sync at this point or let the user sync explicitly. + void this.sync(); // TODO: Should I sync at this point or let the user sync explicitly. this._observers.push(subsriber); const { currentData } = this; if (currentData != null) { @@ -76,36 +78,44 @@ export class DataLine2<TData> { }); } - sync(): void { - const { currentData } = this; - if (currentData != null && currentData.status === "syncing") return; - this.next({ data: currentData?.data ?? null, status: "syncing" }); - void this.config.getSavedData().then((savedData) => { - if (currentData == null && savedData != null) { - this.next({ data: savedData, status: "syncing" }); - } - return this.config.fetchData(savedData).then((data) => { - if (data == null) { - this.next({ - data: savedData, - status: "offline", - }); - } else { - return this.config.saveData(data).then(() => { - this.next({ data: data, status: "synced" }); - }); - } - }); + private syncWithAction(action: () => Promise<void>): Promise<void> { + if (this._syncPromise != null) return this._syncPromise; + this._syncPromise = action().then(() => { + this._syncPromise = null; }); + return this._syncPromise; + } + + sync(): Promise<void> { + return this.syncWithAction(this.doSync.bind(this)); } - save(data: TData): void { + private async doSync(): Promise<void> { const { currentData } = this; - if (currentData != null && currentData.status === "syncing") return; this.next({ data: currentData?.data ?? null, status: "syncing" }); - void this.config.saveData(data).then(() => { + const savedData = await this.config.getSavedData(); + if (currentData == null && savedData != null) { + this.next({ data: savedData, status: "syncing" }); + } + const data = await this.config.fetchData(savedData); + if (data == null) { + this.next({ + data: savedData, + status: "offline", + }); + } else { + await this.config.saveData(data); this.next({ data: data, status: "synced" }); - }); + } + } + + save(data: TData): Promise<void> { + return this.syncWithAction(this.doSave.bind(this, data)); + } + + private async doSave(data: TData): Promise<void> { + await this.config.saveData(data); + this.next({ data: data, status: "synced" }); } getSavedData(): Promise<TData | null> { diff --git a/FrontEnd/src/app/services/timeline.ts b/FrontEnd/src/app/services/timeline.ts index 8bc1d40b..46671ea1 100644 --- a/FrontEnd/src/app/services/timeline.ts +++ b/FrontEnd/src/app/services/timeline.ts @@ -26,6 +26,8 @@ export type { TimelineVisibility } from "@/http/timeline"; import { dataStorage } from "./common"; import { userInfoService, AuthUser } from "./user"; import { DataAndStatus, DataHub2 } from "./DataHub2"; +import { getHttpBookmarkClient } from "@/http/bookmark"; +import { getHttpHighlightClient } from "@/http/highlight"; export type TimelineInfo = HttpTimelineInfo; export type TimelineChangePropertyRequest = HttpTimelinePatchRequest; @@ -104,8 +106,9 @@ export class TimelineService { saveData: async (timelineName, data) => { if (data === "notexist") return; - userInfoService.saveUser(data.owner); - userInfoService.saveUsers(data.members); + // TODO: Avoid save same user. + void userInfoService.saveUser(data.owner); + void userInfoService.saveUsers(data.members); await dataStorage.setItem<TimelineData>( this.generateTimelineDataStorageKey(timelineName), @@ -157,8 +160,8 @@ export class TimelineService { }, }); - syncTimeline(timelineName: string): void { - this.timelineHub.getLine(timelineName).sync(); + syncTimeline(timelineName: string): Promise<void> { + return this.timelineHub.getLine(timelineName).sync(); } createTimeline(timelineName: string): Observable<TimelineInfo> { @@ -174,15 +177,12 @@ export class TimelineService { changeTimelineProperty( timelineName: string, req: TimelineChangePropertyRequest - ): Observable<TimelineInfo> { - return from( - getHttpTimelineClient() - .patchTimeline(timelineName, req) - .then((timeline) => { - void this.syncTimeline(timelineName); - return timeline; - }) - ); + ): Promise<void> { + return getHttpTimelineClient() + .patchTimeline(timelineName, req) + .then(() => { + void this.syncTimeline(timelineName); + }); } deleteTimeline(timelineName: string): Observable<unknown> { @@ -222,7 +222,7 @@ export class TimelineService { }; data.posts.forEach((p) => { - userInfoService.saveUser(p.author); + void userInfoService.saveUser(p.author); }); await dataStorage.setItem<TimelinePostsData>( @@ -342,31 +342,27 @@ export class TimelineService { }, }); - syncPosts(timelineName: string): void { - this.postsHub.getLine(timelineName).sync(); + syncPosts(timelineName: string): Promise<void> { + return this.postsHub.getLine(timelineName).sync(); } createPost( timelineName: string, request: TimelineCreatePostRequest - ): Observable<unknown> { - return from( - getHttpTimelineClient() - .postPost(timelineName, request) - .then(() => { - this.syncPosts(timelineName); - }) - ); + ): Promise<void> { + return getHttpTimelineClient() + .postPost(timelineName, request) + .then(() => { + void this.syncPosts(timelineName); + }); } - deletePost(timelineName: string, postId: number): Observable<unknown> { - return from( - getHttpTimelineClient() - .deletePost(timelineName, postId) - .then(() => { - this.syncPosts(timelineName); - }) - ); + deletePost(timelineName: string, postId: number): Promise<void> { + return getHttpTimelineClient() + .deletePost(timelineName, postId) + .then(() => { + void this.syncPosts(timelineName); + }); } isMemberOf(username: string, timeline: TimelineInfo): boolean { @@ -435,6 +431,26 @@ export class TimelineService { user.username === post.author.username) ); } + + setHighlight(timelineName: string, highlight: boolean): Promise<void> { + const client = getHttpHighlightClient(); + const promise = highlight + ? client.put(timelineName) + : client.delete(timelineName); + return promise.then(() => { + void timelineService.syncTimeline(timelineName); + }); + } + + setBookmark(timelineName: string, bookmark: boolean): Promise<void> { + const client = getHttpBookmarkClient(); + const promise = bookmark + ? client.put(timelineName) + : client.delete(timelineName); + return promise.then(() => { + void timelineService.syncTimeline(timelineName); + }); + } } export const timelineService = new TimelineService(); diff --git a/FrontEnd/src/app/services/user.ts b/FrontEnd/src/app/services/user.ts index 5c4e3ae0..611a86ae 100644 --- a/FrontEnd/src/app/services/user.ts +++ b/FrontEnd/src/app/services/user.ts @@ -248,12 +248,12 @@ export function checkLogin(): AuthUser { export class UserNotExistError extends Error {} export class UserInfoService { - saveUser(user: HttpUser): void { - this.userHub.getLine(user.username).save(user); + saveUser(user: HttpUser): Promise<void> { + return this.userHub.getLine(user.username).save(user); } - saveUsers(users: HttpUser[]): void { - return users.forEach((user) => this.saveUser(user)); + saveUsers(users: HttpUser[]): Promise<void> { + return Promise.all(users.map((user) => this.saveUser(user))).then(); } async getCachedUser(username: string): Promise<HttpUser | null> { @@ -351,15 +351,13 @@ export class UserInfoService { async setAvatar(username: string, blob: Blob): Promise<void> { const etag = await getHttpUserClient().putAvatar(username, blob); - this.avatarHub.getLine(username).save({ data: blob, etag }); + await this.avatarHub.getLine(username).save({ data: blob, etag }); } async setNickname(username: string, nickname: string): Promise<void> { return getHttpUserClient() .patch(username, { nickname }) - .then((user) => { - this.saveUser(user); - }); + .then((user) => this.saveUser(user)); } } diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx index f8b2b38b..fc4c52ec 100644 --- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx +++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplate.tsx @@ -5,15 +5,15 @@ import { UiLogicError } from "@/common"; import { pushAlert } from "@/services/alert"; import { useUser } from "@/services/user"; import { timelineService, usePosts, useTimeline } from "@/services/timeline"; -import { getHttpBookmarkClient } from "@/http/bookmark"; -import { getHttpHighlightClient } from "@/http/highlight"; +import { mergeDataStatus } from "@/services/DataHub2"; import { TimelineMemberDialog } from "./TimelineMember"; import TimelinePropertyChangeDialog from "./TimelinePropertyChangeDialog"; -import { TimelinePageTemplateUIProps } from "./TimelinePageTemplateUI"; -import { TimelinePostSendCallback } from "./TimelinePostEdit"; +import { + TimelinePageTemplateData, + TimelinePageTemplateUIProps, +} from "./TimelinePageTemplateUI"; import { TimelinePostInfoEx } from "./Timeline"; -import { mergeDataStatus } from "@/services/DataHub2"; export interface TimelinePageTemplateProps<TManageItem> { name: string; @@ -39,28 +39,37 @@ export default function TimelinePageTemplate<TManageItem>( null ); + const [scrollBottomKey, setScrollBottomKey] = React.useState<number>(0); + + React.useEffect(() => { + if (scrollBottomKey > 0) { + window.scrollTo(0, document.body.scrollHeight); + } + }, [scrollBottomKey]); + const timelineAndStatus = useTimeline(name); const postsAndState = usePosts(name); - const onPost: TimelinePostSendCallback = React.useCallback( - (req) => { - return service.createPost(name, req).toPromise().then(); - }, - [service, name] - ); + const [ + scrollToBottomNextSyncKey, + setScrollToBottomNextSyncKey, + ] = React.useState<number>(0); - const onManageProp = props.onManage; + const scrollToBottomNextSync = (): void => { + setScrollToBottomNextSyncKey((old) => old + 1); + }; - const onManage = React.useCallback( - (item: "property" | TManageItem) => { - if (item === "property") { - setDialog(item); - } else { - onManageProp(item); + React.useEffect(() => { + let subscribe = true; + void timelineService.syncPosts(name).then(() => { + if (subscribe) { + setScrollBottomKey((old) => old + 1); } - }, - [onManageProp] - ); + }); + return () => { + subscribe = false; + }; + }, [name, scrollToBottomNextSyncKey]); const data = ((): TimelinePageTemplateUIProps<TManageItem>["data"] => { const { status, data: timeline } = timelineAndStatus; @@ -84,13 +93,11 @@ export default function TimelinePageTemplate<TManageItem>( ...post, onDelete: service.hasModifyPostPermission(user, timeline, post) ? () => { - service.deletePost(name, post.id).subscribe({ - error: () => { - pushAlert({ - type: "danger", - message: t("timeline.deletePostFailed"), - }); - }, + service.deletePost(name, post.id).catch(() => { + pushAlert({ + type: "danger", + message: t("timeline.deletePostFailed"), + }); }); } : undefined, @@ -98,62 +105,55 @@ export default function TimelinePageTemplate<TManageItem>( } })(); - const operations = { - onPost: service.hasPostPermission(user, timeline) ? onPost : undefined, + const operations: TimelinePageTemplateData<TManageItem>["operations"] = { + onPost: service.hasPostPermission(user, timeline) + ? (req) => + service.createPost(name, req).then(() => scrollToBottomNextSync()) + : undefined, onManage: service.hasManagePermission(user, timeline) - ? onManage + ? (item) => { + if (item === "property") { + setDialog(item); + } else { + props.onManage(item); + } + } : undefined, onMember: () => setDialog("member"), onBookmark: user != null ? () => { - const { isBookmark } = timeline; - const client = getHttpBookmarkClient(); - const promise = isBookmark - ? client.delete(name) - : client.put(name); - promise.then( - () => { - void timelineService.syncTimeline(name); - }, - () => { + service + .setBookmark(timeline.name, !timeline.isBookmark) + .catch(() => { pushAlert({ message: { type: "i18n", - key: isBookmark + key: timeline.isBookmark ? "timeline.removeBookmarkFail" : "timeline.addBookmarkFail", }, type: "danger", }); - } - ); + }); } : undefined, onHighlight: user != null && user.hasHighlightTimelineAdministrationPermission ? () => { - const { isHighlight } = timeline; - const client = getHttpHighlightClient(); - const promise = isHighlight - ? client.delete(name) - : client.put(name); - promise.then( - () => { - void timelineService.syncTimeline(name); - }, - () => { + service + .setHighlight(timeline.name, !timeline.isHighlight) + .catch(() => { pushAlert({ message: { type: "i18n", - key: isHighlight + key: timeline.isHighlight ? "timeline.removeHighlightFail" : "timeline.addHighlightFail", }, type: "danger", }); - } - ); + }); } : undefined, }; @@ -162,13 +162,9 @@ export default function TimelinePageTemplate<TManageItem>( } })(); - const closeDialog = React.useCallback((): void => { - setDialog(null); - }, []); - - let dialogElement: React.ReactElement | undefined; - const timeline = timelineAndStatus?.data; + let dialogElement: React.ReactElement | undefined; + const closeDialog = (): void => setDialog(null); if (dialog === "property") { if (timeline == null || timeline === "notexist") { @@ -181,14 +177,8 @@ export default function TimelinePageTemplate<TManageItem>( <TimelinePropertyChangeDialog open close={closeDialog} - oldInfo={{ - title: timeline.title, - visibility: timeline.visibility, - description: timeline.description, - }} - onProcess={(req) => { - return service.changeTimelineProperty(name, req).toPromise().then(); - }} + timeline={timeline} + onProcess={(req) => service.changeTimelineProperty(name, req)} /> ); } else if (dialog === "member") { diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx index 41246175..0d0951ee 100644 --- a/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx +++ b/FrontEnd/src/app/views/timeline-common/TimelinePageTemplateUI.tsx @@ -1,10 +1,9 @@ import React from "react"; import { useTranslation } from "react-i18next"; -import { fromEvent } from "rxjs"; import { Spinner } from "react-bootstrap"; import { getAlertHost } from "@/services/alert"; -import { useEventEmiiter, I18nText, convertI18nText } from "@/common"; +import { I18nText, convertI18nText } from "@/common"; import { TimelineInfo } from "@/services/timeline"; import Timeline, { TimelinePostInfoEx } from "./Timeline"; @@ -25,20 +24,20 @@ export interface TimelineCardComponentProps<TManageItems> { className?: string; } +export interface TimelinePageTemplateData<TManageItems> { + timeline: TimelineInfo; + posts?: TimelinePostInfoEx[] | "forbid"; + operations: { + onManage?: (item: TManageItems | "property") => void; + onMember: () => void; + onBookmark?: () => void; + onHighlight?: () => void; + onPost?: TimelinePostSendCallback; + }; +} + export interface TimelinePageTemplateUIProps<TManageItems> { - data?: - | { - timeline: TimelineInfo; - posts?: TimelinePostInfoEx[] | "forbid"; - operations: { - onManage?: (item: TManageItems | "property") => void; - onMember: () => void; - onBookmark?: () => void; - onHighlight?: () => void; - onPost?: TimelinePostSendCallback; - }; - } - | I18nText; + data?: TimelinePageTemplateData<TManageItems> | I18nText; syncStatus: TimelineSyncStatus; CardComponent: React.ComponentType<TimelineCardComponentProps<TManageItems>>; } @@ -69,60 +68,9 @@ export default function TimelinePageTemplateUI<TManageItems>( const timelineRef = React.useRef<HTMLDivElement | null>(null); - const [getResizeEvent, triggerResizeEvent] = useEventEmiiter(); - const timelineName: string | null = typeof data === "object" && "timeline" in data ? data.timeline.name : null; - React.useEffect(() => { - const { current: timelineElement } = timelineRef; - if (timelineElement != null) { - let loadingScrollToBottom = true; - let pinBottom = false; - - const isAtBottom = (): boolean => - window.innerHeight + window.scrollY + 10 >= document.body.scrollHeight; - - const disableLoadingScrollToBottom = (): void => { - loadingScrollToBottom = false; - if (isAtBottom()) pinBottom = true; - }; - - const checkAndScrollToBottom = (): void => { - if (loadingScrollToBottom || pinBottom) { - window.scrollTo(0, document.body.scrollHeight); - } - }; - - const subscriptions = [ - fromEvent(timelineElement, "wheel").subscribe( - disableLoadingScrollToBottom - ), - fromEvent(timelineElement, "pointerdown").subscribe( - disableLoadingScrollToBottom - ), - fromEvent(timelineElement, "keydown").subscribe( - disableLoadingScrollToBottom - ), - fromEvent(window, "scroll").subscribe(() => { - if (loadingScrollToBottom) return; - - if (isAtBottom()) { - pinBottom = true; - } else { - pinBottom = false; - } - }), - fromEvent(window, "resize").subscribe(checkAndScrollToBottom), - getResizeEvent().subscribe(checkAndScrollToBottom), - ]; - - return () => { - subscriptions.forEach((s) => s.unsubscribe()); - }; - } - }, [getResizeEvent, triggerResizeEvent, timelineName]); - const cardCollapseLocalStorageKey = timelineName != null ? `timeline.${timelineName}.cardCollapse` : null; @@ -173,11 +121,7 @@ export default function TimelinePageTemplateUI<TManageItems>( className="timeline-container" style={{ minHeight: `calc(100vh - ${56 + bottomSpaceHeight}px)` }} > - <Timeline - containerRef={timelineRef} - posts={posts} - onResize={triggerResizeEvent} - /> + <Timeline containerRef={timelineRef} posts={posts} /> </div> ) ) : ( diff --git a/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx b/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx index aae227e6..ab3285f5 100644 --- a/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx +++ b/FrontEnd/src/app/views/timeline-common/TimelinePropertyChangeDialog.tsx @@ -4,20 +4,15 @@ import { TimelineVisibility, kTimelineVisibilities, TimelineChangePropertyRequest, + TimelineInfo, } from "@/services/timeline"; import OperationDialog from "../common/OperationDialog"; -export interface TimelinePropertyInfo { - title: string; - visibility: TimelineVisibility; - description: string; -} - export interface TimelinePropertyChangeDialogProps { open: boolean; close: () => void; - oldInfo: TimelinePropertyInfo; + timeline: TimelineInfo; onProcess: (request: TimelineChangePropertyRequest) => Promise<void>; } @@ -30,6 +25,8 @@ const labelMap: { [key in TimelineVisibility]: string } = { const TimelinePropertyChangeDialog: React.FC<TimelinePropertyChangeDialogProps> = ( props ) => { + const { timeline } = props; + return ( <OperationDialog title={"timeline.dialogChangeProperty.title"} @@ -37,7 +34,7 @@ const TimelinePropertyChangeDialog: React.FC<TimelinePropertyChangeDialogProps> { type: "text", label: "timeline.dialogChangeProperty.titleField", - initValue: props.oldInfo.title, + initValue: timeline.title, }, { type: "select", @@ -46,25 +43,25 @@ const TimelinePropertyChangeDialog: React.FC<TimelinePropertyChangeDialogProps> label: labelMap[v], value: v, })), - initValue: props.oldInfo.visibility, + initValue: timeline.visibility, }, { type: "text", label: "timeline.dialogChangeProperty.description", - initValue: props.oldInfo.description, + initValue: timeline.description, }, ]} open={props.open} close={props.close} onProcess={([newTitle, newVisibility, newDescription]) => { const req: TimelineChangePropertyRequest = {}; - if (newTitle !== props.oldInfo.title) { + if (newTitle !== timeline.title) { req.title = newTitle; } - if (newVisibility !== props.oldInfo.visibility) { + if (newVisibility !== timeline.visibility) { req.visibility = newVisibility as TimelineVisibility; } - if (newDescription !== props.oldInfo.description) { + if (newDescription !== timeline.description) { req.description = newDescription; } return props.onProcess(req); diff --git a/FrontEnd/src/app/views/user/index.tsx b/FrontEnd/src/app/views/user/index.tsx index 7c0b1563..bb986178 100644 --- a/FrontEnd/src/app/views/user/index.tsx +++ b/FrontEnd/src/app/views/user/index.tsx @@ -1,8 +1,7 @@ import React, { useState } from "react"; import { useParams } from "react-router"; -import { UiLogicError } from "@/common"; -import { useUser, userInfoService } from "@/services/user"; +import { userInfoService } from "@/services/user"; import TimelinePageTemplate from "../timeline-common/TimelinePageTemplate"; @@ -14,54 +13,38 @@ import ChangeAvatarDialog from "./ChangeAvatarDialog"; const UserPage: React.FC = (_) => { const { username } = useParams<{ username: string }>(); - const user = useUser(); - const [dialog, setDialog] = useState<null | PersonalTimelineManageItem>(null); let dialogElement: React.ReactElement | undefined; - const closeDialogHandler = (): void => { - setDialog(null); - }; + const closeDialog = (): void => setDialog(null); if (dialog === "nickname") { - if (user == null) { - throw new UiLogicError("Change nickname without login."); - } - dialogElement = ( <ChangeNicknameDialog open - close={closeDialogHandler} + close={closeDialog} onProcess={(newNickname) => userInfoService.setNickname(username, newNickname) } /> ); } else if (dialog === "avatar") { - if (user == null) { - throw new UiLogicError("Change avatar without login."); - } - dialogElement = ( <ChangeAvatarDialog open - close={closeDialogHandler} + close={closeDialog} process={(file) => userInfoService.setAvatar(username, file)} /> ); } - const onManage = React.useCallback((item: PersonalTimelineManageItem) => { - setDialog(item); - }, []); - return ( <> <TimelinePageTemplate name={`@${username}`} UiComponent={UserPageUI} - onManage={onManage} + onManage={(item) => setDialog(item)} notFoundI18nKey="timeline.userNotExist" /> {dialogElement} |